Files
jundot--omlx/tests/test_mlx_lm_mtp_patch.py
wehub-resource-sync e9a2f726c9
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
CI / test (3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:29:51 +08:00

1848 lines
70 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Tests for omlx.patches.mlx_lm_mtp.
Phase 1 covers the model-side hooks (PR 990 for Qwen3.5/3.6 + PR 15
skeleton for DeepSeek-V4) and the conditional dispatch in
``GenerationBatch.next``. End-to-end MTP draft/verify is exercised in a
follow-up once the BatchGenerator integration body is filled in.
"""
from __future__ import annotations
import json
from types import SimpleNamespace
import pytest
from omlx.model_settings import ModelSettings
from omlx.utils.model_loading import (
_has_mtp_heads,
_is_mtp_compatible,
maybe_apply_pre_load_patches,
)
# ---------------------------------------------------------------------------
# Patch orchestrator + sub-modules
# ---------------------------------------------------------------------------
class TestApplyOrchestrator:
def test_apply_idempotent(self):
from omlx.patches.mlx_lm_mtp import apply_mlx_lm_mtp_patch
first = apply_mlx_lm_mtp_patch()
second = apply_mlx_lm_mtp_patch()
# Both calls must succeed; the second is a no-op but still True.
assert first is True
assert second is True
def test_module_imports_without_mlx_lm(self, monkeypatch):
"""Importing the package must not fail even if mlx_lm is unavailable."""
# Just exercise the import path; sub-modules are deferred to apply().
import omlx.patches.mlx_lm_mtp as mtp # noqa: F401
class TestCacheRollback:
def test_arrays_cache_gains_rollback_slot(self):
from omlx.patches.mlx_lm_mtp import cache_rollback
applied = cache_rollback.apply()
assert applied is True
try:
from mlx_lm.models.cache import ArraysCache
except ImportError:
pytest.skip("mlx-lm not importable")
assert hasattr(ArraysCache, "rollback_state")
# rollback_state default is None until a draft+verify writes to it.
cache = ArraysCache(size=2)
assert cache.rollback_state is None
class TestQwen35Model:
@pytest.fixture(autouse=True)
def _apply(self):
try:
from omlx.patches.mlx_lm_mtp import qwen35_model
except ImportError:
pytest.skip("omlx.patches.mlx_lm_mtp not importable")
applied = qwen35_model.apply()
if not applied:
pytest.skip("qwen35_model patch refused to apply (likely mlx_lm absent)")
def test_text_model_args_from_dict_preserves_mtp_layers(self):
from mlx_lm.models.qwen3_5 import TextModelArgs
args = TextModelArgs.from_dict(
{
"model_type": "qwen3_5",
"hidden_size": 64,
"intermediate_size": 128,
"num_hidden_layers": 4,
"num_attention_heads": 4,
"num_key_value_heads": 2,
"vocab_size": 256,
"linear_num_value_heads": 2,
"linear_num_key_heads": 2,
"linear_key_head_dim": 16,
"linear_value_head_dim": 16,
"linear_conv_kernel_dim": 3,
"full_attention_interval": 2,
"tie_word_embeddings": True,
"rms_norm_eps": 1e-5,
"head_dim": 32,
"rope_theta": 1000.0,
"partial_rotary_factor": 0.5,
"max_position_embeddings": 128,
"mtp_num_hidden_layers": 1,
}
)
assert getattr(args, "mtp_num_hidden_layers", None) == 1
def test_text_model_args_default_zero_when_missing(self):
from mlx_lm.models.qwen3_5 import TextModelArgs
args = TextModelArgs.from_dict(
{
"model_type": "qwen3_5",
"hidden_size": 64,
"intermediate_size": 128,
"num_hidden_layers": 4,
"num_attention_heads": 4,
"num_key_value_heads": 2,
"vocab_size": 256,
"linear_num_value_heads": 2,
"linear_num_key_heads": 2,
"linear_key_head_dim": 16,
"linear_value_head_dim": 16,
"linear_conv_kernel_dim": 3,
"full_attention_interval": 2,
"tie_word_embeddings": True,
"rms_norm_eps": 1e-5,
"head_dim": 32,
"rope_theta": 1000.0,
"partial_rotary_factor": 0.5,
"max_position_embeddings": 128,
}
)
assert getattr(args, "mtp_num_hidden_layers", None) == 0
def test_mtp_classes_registered_on_module(self):
from mlx_lm.models import qwen3_5
assert hasattr(qwen3_5, "MTPModule")
assert hasattr(qwen3_5, "MTPDecoderLayer")
def test_text_model_class_has_mtp_forward(self):
from mlx_lm.models.qwen3_5 import TextModel
# Methods are attached unconditionally; the per-instance ``mtp``
# module is gated by the active-flag set right before mlx_lm.load.
assert hasattr(TextModel, "mtp_forward")
assert hasattr(TextModel, "make_mtp_cache")
assert hasattr(TextModel, "_omlx_mtp_patched")
def test_set_mtp_active_toggles_module_flag(self):
"""The active-flag controls whether subsequent loads attach self.mtp."""
from omlx.patches.mlx_lm_mtp import is_mtp_active, set_mtp_active
prev = is_mtp_active()
try:
set_mtp_active(False)
assert is_mtp_active() is False
set_mtp_active(True)
assert is_mtp_active() is True
finally:
set_mtp_active(prev)
def test_outer_model_pass_through_methods(self):
from mlx_lm.models.qwen3_5 import Model
assert hasattr(Model, "mtp_forward")
assert hasattr(Model, "make_mtp_cache")
assert hasattr(Model, "_omlx_mtp_patched")
def test_decoder_layer_omits_n_confirmed_when_zero(self):
"""DFlash replaces linear_attn.__call__ with a hook that has no
n_confirmed param. The patched DecoderLayer must not pass the kwarg
on the n_confirmed==0 path (stock / DFlash). Regression for #1318.
"""
from mlx_lm.models.qwen3_5 import DecoderLayer
seen = {"passed": None}
# Mimic DFlash's speculative hook: no n_confirmed parameter.
def linear_attn_no_kwarg(h, mask=None, cache=None):
seen["passed"] = False
return h
fake = SimpleNamespace(
is_linear=True,
input_layernorm=lambda x: x,
post_attention_layernorm=lambda x: x,
linear_attn=linear_attn_no_kwarg,
mlp=lambda x: 0.0,
)
# Must not raise TypeError on the unexpected n_confirmed kwarg.
DecoderLayer.__call__(fake, 0.0, mask=None, cache=None, n_confirmed=0)
assert seen["passed"] is False
def test_decoder_layer_forwards_n_confirmed_when_nonzero(self):
"""The MTP draft/verify path (n_confirmed>0) still threads the kwarg."""
from mlx_lm.models.qwen3_5 import DecoderLayer
seen = {"n_confirmed": None}
def linear_attn_with_kwarg(h, mask=None, cache=None, n_confirmed=0):
seen["n_confirmed"] = n_confirmed
return h
fake = SimpleNamespace(
is_linear=True,
input_layernorm=lambda x: x,
post_attention_layernorm=lambda x: x,
linear_attn=linear_attn_with_kwarg,
mlp=lambda x: 0.0,
)
DecoderLayer.__call__(fake, 0.0, mask=None, cache=None, n_confirmed=3)
assert seen["n_confirmed"] == 3
class TestQwen35MtpNormShift:
"""Per-key +1 RMSNorm shift for mixed-convention MTP checkpoints (PR #1507).
Some pre-quantized Qwen3.6 MXFP4 bundles ship MTP-head norms in a mixed
convention: ``mtp.norm`` already in MLX's +1 convention (mean ~1.27) while
the per-layer head norms are still raw-HF (mean ~0). The backbone-only
conv1d signal evaluates False for such a checkpoint, so the old global
flag left the raw-HF head norms unshifted and MTP acceptance collapsed to
~0%. The fix decides the shift per-key from each weight's own magnitude.
"""
@pytest.fixture(autouse=True)
def _apply(self):
try:
from omlx.patches.mlx_lm_mtp import qwen35_model
except ImportError:
pytest.skip("omlx.patches.mlx_lm_mtp not importable")
if not qwen35_model.apply():
pytest.skip("qwen35_model patch refused to apply")
def _model(self):
from mlx_lm.models.qwen3_5 import TextModel
m = TextModel.__new__(TextModel)
m.mtp = SimpleNamespace() # presence keeps mtp.* keys in sanitize
m.args = SimpleNamespace(tie_word_embeddings=False)
return m
@staticmethod
def _first(arr):
return float(arr[0])
def test_mixed_convention_shifts_only_raw_hf_mtp_norms(self):
"""No unsanitized conv1d (backbone already MLX) -> should_shift False.
Raw-HF head norms get +1, already-MLX siblings are left untouched."""
import mlx.core as mx
m = self._model()
weights = {
# Already-MLX (mean >= 0.5) -> must NOT shift.
"mtp.norm.weight": mx.full((16,), 1.27),
"mtp.layers.0.self_attn.q_norm.weight": mx.full((16,), 0.75),
"mtp.layers.0.self_attn.k_norm.weight": mx.full((16,), 0.74),
# Raw-HF (mean < 0.5) -> must shift by +1.
"mtp.layers.0.input_layernorm.weight": mx.full((16,), 0.04),
"mtp.layers.0.post_attention_layernorm.weight": mx.full((16,), 0.21),
"mtp.pre_fc_norm_embedding.weight": mx.full((16,), -0.44),
"mtp.pre_fc_norm_hidden.weight": mx.full((16,), -0.17),
}
out = m.sanitize(weights)
g = self._first
# Already-MLX siblings left untouched.
assert abs(g(out["mtp.norm.weight"]) - 1.27) < 1e-3
assert abs(g(out["mtp.layers.0.self_attn.q_norm.weight"]) - 0.75) < 1e-3
assert abs(g(out["mtp.layers.0.self_attn.k_norm.weight"]) - 0.74) < 1e-3
# Raw-HF head norms shifted by +1.
assert abs(g(out["mtp.layers.0.input_layernorm.weight"]) - 1.04) < 1e-3
assert abs(g(out["mtp.layers.0.post_attention_layernorm.weight"]) - 1.21) < 1e-3
assert abs(g(out["mtp.pre_fc_norm_embedding.weight"]) - 0.56) < 1e-3
assert abs(g(out["mtp.pre_fc_norm_hidden.weight"]) - 0.83) < 1e-3
def test_pure_raw_hf_shifts_backbone_and_mtp(self):
"""Unsanitized conv1d present -> should_shift True. Backbone and all
raw-HF MTP norms get +1 (matches the legacy global-flag behavior)."""
import mlx.core as mx
m = self._model()
weights = {
# shape[-1] != 1 marks a raw-HF checkpoint -> should_shift True.
"model.layers.0.self_attn.conv1d.weight": mx.zeros((8, 4, 3)),
"model.layers.0.input_layernorm.weight": mx.full((16,), 0.05),
"mtp.layers.0.input_layernorm.weight": mx.full((16,), 0.04),
"mtp.norm.weight": mx.full((16,), 0.27),
}
out = m.sanitize(weights)
g = self._first
assert abs(g(out["model.layers.0.input_layernorm.weight"]) - 1.05) < 1e-3
assert abs(g(out["mtp.layers.0.input_layernorm.weight"]) - 1.04) < 1e-3
assert abs(g(out["mtp.norm.weight"]) - 1.27) < 1e-3
def test_pure_mlx_leaves_everything_untouched(self):
"""Already-converted checkpoint: no conv1d signal and all norms in the
+1 convention -> nothing is shifted (idempotent re-sanitize)."""
import mlx.core as mx
m = self._model()
weights = {
"model.layers.0.input_layernorm.weight": mx.full((16,), 1.05),
"mtp.layers.0.input_layernorm.weight": mx.full((16,), 1.04),
"mtp.norm.weight": mx.full((16,), 1.27),
}
out = m.sanitize(weights)
g = self._first
assert abs(g(out["model.layers.0.input_layernorm.weight"]) - 1.05) < 1e-3
assert abs(g(out["mtp.layers.0.input_layernorm.weight"]) - 1.04) < 1e-3
assert abs(g(out["mtp.norm.weight"]) - 1.27) < 1e-3
def test_oq_discovery_keeps_mtp_norm_shift_on_raw_hf_source(self):
"""oQ streaming-plan discovery runs sanitize on no-data _TrackedTensor
placeholders. On a raw-HF source (unsanitized conv1d present) every
Qwen3-Next RMSNorm gamma is zero-centered, so MTP-head norms record
the same unconditional +1 "add" transform as the backbone norms.
(The old conditional add_if_mean_lt_0_5 misclassified q_norm/k_norm
[raw mean ~0.75] and mtp.norm [raw ~1.27], costing ~14pp of draft
acceptance on Qwen3.6-27B.) Pre-converted sources — no unsanitized
conv1d — keep the per-key conditional for mixed-convention bundles."""
import mlx.core as mx
from omlx.oq import _discover_sanitize_plan
m = self._model()
class _FakeIdx:
def __init__(self, meta):
self._meta = meta
def logical_metadata(self):
return self._meta
# conv1d shape[-1] != 1 marks a raw-HF source -> should_shift True.
meta = {
"model.layers.0.self_attn.conv1d.weight": ((2048, 4, 4), mx.float32),
"model.layers.0.input_layernorm.weight": ((16,), mx.float32),
"mtp.layers.0.input_layernorm.weight": ((16,), mx.float32),
"mtp.norm.weight": ((16,), mx.float32),
}
plan = _discover_sanitize_plan(m.sanitize, _FakeIdx(meta))
# Raw-HF source: backbone AND head norms all take the fixed +1 add.
assert plan["model.layers.0.input_layernorm.weight"]["transform"] == "add"
assert plan["mtp.layers.0.input_layernorm.weight"]["transform"] == "add"
assert plan["mtp.norm.weight"]["transform"] == "add"
class TestQwen35MoeSanitize:
"""Regression tests for the MoE MTP sanitize patch (qwen3_5_moe.Model)."""
@pytest.fixture(autouse=True)
def _apply(self):
try:
from omlx.patches.mlx_lm_mtp import qwen35_model
except ImportError:
pytest.skip("omlx.patches.mlx_lm_mtp not importable")
if not qwen35_model.apply():
pytest.skip("qwen35_model patch refused to apply")
from omlx.patches.mlx_lm_mtp.qwen35_model import _patch_qwen3_5_moe
_patch_qwen3_5_moe()
@pytest.fixture()
def moe_model(self):
from types import SimpleNamespace
from mlx_lm.models import qwen3_5_moe as moe
args = SimpleNamespace(
num_hidden_layers=2,
mtp_num_hidden_layers=1,
num_experts=4,
)
inner = SimpleNamespace(args=args, sanitize=lambda w: w)
model = moe.Model.__new__(moe.Model)
model.language_model = inner
return model
def _backbone_weights(self):
import mlx.core as mx
weights = {}
for layer in range(2):
pfx = f"language_model.model.layers.{layer}.mlp"
weights[f"{pfx}.experts.gate_up_proj"] = mx.zeros((4, 128, 64))
weights[f"{pfx}.experts.down_proj"] = mx.zeros((4, 64, 128))
weights["language_model.model.embed_tokens.weight"] = mx.zeros((256, 64))
return weights
def test_sanitize_no_mtp_weights(self, moe_model):
"""Config declares mtp_num_hidden_layers=1 but no MTP weights exist
(model quantized without preserve_mtp). Must not crash, and must
still produce the unfused backbone weights."""
result = moe_model.sanitize(self._backbone_weights())
assert isinstance(result, dict)
assert not any("mtp" in k for k in result)
for layer in range(2):
pfx = f"language_model.model.layers.{layer}.mlp"
assert f"{pfx}.switch_mlp.gate_proj.weight" in result
assert f"{pfx}.switch_mlp.down_proj.weight" in result
assert "language_model.model.embed_tokens.weight" in result
def test_sanitize_switch_mlp_form(self, moe_model):
"""oQ outputs store MTP experts in switch_mlp form — sanitize skips."""
import mlx.core as mx
weights = self._backbone_weights()
pfx = "language_model.mtp.layers.0.mlp"
for proj in ("gate_proj", "up_proj", "down_proj"):
weights[f"{pfx}.switch_mlp.{proj}.weight"] = mx.zeros((4, 64, 128))
result = moe_model.sanitize(weights)
assert f"{pfx}.switch_mlp.gate_proj.weight" in result
def test_sanitize_per_expert_form(self, moe_model):
"""Raw HF Qwen3.5 per-expert tensors stacked into switch_mlp."""
import mlx.core as mx
weights = self._backbone_weights()
pfx = "language_model.mtp.layers.0.mlp"
for e in range(4):
for proj in ("gate_proj", "up_proj", "down_proj"):
weights[f"{pfx}.experts.{e}.{proj}.weight"] = mx.zeros((64, 128))
result = moe_model.sanitize(weights)
assert f"{pfx}.switch_mlp.gate_proj.weight" in result
def test_sanitize_fused_form(self, moe_model):
"""Qwen3.6 fused gate_up_proj unfused into switch_mlp."""
import mlx.core as mx
weights = self._backbone_weights()
pfx = "language_model.mtp.layers.0.mlp"
weights[f"{pfx}.experts.gate_up_proj"] = mx.zeros((4, 128, 64))
weights[f"{pfx}.experts.down_proj"] = mx.zeros((4, 64, 128))
result = moe_model.sanitize(weights)
assert f"{pfx}.switch_mlp.gate_proj.weight" in result
def test_sanitize_dense_mtplx_form(self, moe_model):
"""MTPLX-format checkpoints ship a dense MLP at the MTP layer
(no ``experts.*`` keys). Sanitize must short-circuit, not attempt
to stack non-existent per-expert tensors.
Regression guard for samuelfaj/Ornstein3.6-35B-A3B-SABER-6bit-MTPLX.
"""
import mlx.core as mx
weights = self._backbone_weights()
pfx = "language_model.mtp.layers.0.mlp"
weights[f"{pfx}.gate_proj.weight"] = mx.zeros((64, 128))
weights[f"{pfx}.up_proj.weight"] = mx.zeros((64, 128))
weights[f"{pfx}.down_proj.weight"] = mx.zeros((128, 64))
weights[f"{pfx}.gate.weight"] = mx.zeros((4, 64))
weights[f"{pfx}.shared_expert.gate_proj.weight"] = mx.zeros((64, 128))
result = moe_model.sanitize(weights)
# Dense MTP keys survive untouched.
assert f"{pfx}.gate_proj.weight" in result
assert f"{pfx}.shared_expert.gate_proj.weight" in result
# No bogus switch_mlp keys synthesized for the dense layer.
assert f"{pfx}.switch_mlp.gate_proj.weight" not in result
def test_sanitize_backbone_per_expert_form(self, moe_model):
"""Ornith / raw Qwen3.5 ship *backbone* MoE layers as per-expert
tensors. Sanitize must stack them into switch_mlp, leaving no orphan
``experts.{N}.*`` keys behind."""
import mlx.core as mx
weights = {"language_model.model.embed_tokens.weight": mx.zeros((256, 64))}
for layer in range(2):
pfx = f"language_model.model.layers.{layer}.mlp"
for e in range(4):
weights[f"{pfx}.experts.{e}.gate_proj.weight"] = mx.zeros((128, 64))
weights[f"{pfx}.experts.{e}.up_proj.weight"] = mx.zeros((128, 64))
weights[f"{pfx}.experts.{e}.down_proj.weight"] = mx.zeros((64, 128))
result = moe_model.sanitize(weights)
for layer in range(2):
pfx = f"language_model.model.layers.{layer}.mlp"
# Per-expert weights stacked: leading dim == num_experts (4).
assert result[f"{pfx}.switch_mlp.gate_proj.weight"].shape == (4, 128, 64)
assert result[f"{pfx}.switch_mlp.up_proj.weight"].shape == (4, 128, 64)
assert result[f"{pfx}.switch_mlp.down_proj.weight"].shape == (4, 64, 128)
# No orphan per-expert keys survive.
assert not any(f"{pfx}.experts." in k for k in result)
def test_sanitize_backbone_per_expert_quantized(self, moe_model):
"""A per-expert *quantized* backbone carries ``.scales``/``.biases``
alongside ``.weight``. All three must be stacked, or the leftover
per-expert scales/biases trip 'Received N parameters not in model'."""
import mlx.core as mx
pfx = "language_model.model.layers.0.mlp"
weights = {"language_model.model.embed_tokens.weight": mx.zeros((256, 64))}
for e in range(4):
for proj in ("gate_proj", "up_proj", "down_proj"):
weights[f"{pfx}.experts.{e}.{proj}.weight"] = mx.zeros((128, 16))
weights[f"{pfx}.experts.{e}.{proj}.scales"] = mx.zeros((128, 2))
weights[f"{pfx}.experts.{e}.{proj}.biases"] = mx.zeros((128, 2))
result = moe_model.sanitize(weights)
for proj in ("gate_proj", "up_proj", "down_proj"):
for suffix in ("weight", "scales", "biases"):
key = f"{pfx}.switch_mlp.{proj}.{suffix}"
assert key in result, key
assert result[key].shape[0] == 4 # stacked over experts
# No orphan per-expert metadata survives.
assert not any(f"{pfx}.experts." in k for k in result)
class TestDeepseekV4Model:
def test_skip_when_base_patch_not_applied(self, monkeypatch):
"""deepseek_v4 MTP patch must skip cleanly if the base
DeepSeek-V4 module hasn't been registered (= non-DeepSeek model)."""
from omlx.patches.mlx_lm_mtp import deepseek_v4_model
# Simulate the base patch not having run by removing the module.
# No module-level _PATCHED to reset anymore — sub-patcher does its
# own marker-based idempotency check against the live class state.
monkeypatch.setitem(
__import__("sys").modules, "mlx_lm.models.deepseek_v4", None
)
# When the module is None / missing, apply() returns False without
# raising — that's the contract for non-DeepSeek models.
applied = deepseek_v4_model.apply()
assert applied is False
def test_apply_with_base_patch_registers_mtp_block(self):
"""When the DeepSeek-V4 base patch has run, our patch should attach
``MTPBlock`` to the module + ``mtp_forward`` / ``make_mtp_cache``
to the Model class. Skipped if the base patch's prerequisites are
not satisfied in this environment.
"""
try:
from omlx.patches.deepseek_v4 import apply_deepseek_v4_patch
except ImportError:
pytest.skip("omlx.patches.deepseek_v4 not importable")
if not apply_deepseek_v4_patch():
pytest.skip("DeepSeek-V4 base patch refused to apply in this env")
from omlx.patches.mlx_lm_mtp import deepseek_v4_model
applied = deepseek_v4_model.apply()
assert applied is True
import sys
dsv4 = sys.modules["mlx_lm.models.deepseek_v4"]
assert hasattr(dsv4, "MTPBlock")
assert hasattr(dsv4.Model, "mtp_forward")
assert hasattr(dsv4.Model, "make_mtp_cache")
assert hasattr(dsv4.Model, "_omlx_mtp_patched")
# Idempotent.
applied_again = deepseek_v4_model.apply()
assert applied_again is True
def test_mtp_patch_materializes_backbone_and_mtp_cache(self):
"""DeepSeek-V4 MTP override must keep the base Metal leak fix."""
import inspect
from omlx.patches.mlx_lm_mtp import deepseek_v4_model
call_source = inspect.getsource(deepseek_v4_model._patch_deepseek_v4_model_call)
model_source = inspect.getsource(deepseek_v4_model._patch_model)
assert "materialize_cache_arrays(cache)" in call_source
assert "materialize_cache_arrays(cache)" in model_source
class TestBatchGeneratorDispatch:
@pytest.fixture(autouse=True)
def _apply(self):
from omlx.patches.mlx_lm_mtp import batch_generator
applied = batch_generator.apply()
if not applied:
pytest.skip("batch_generator patch refused to apply (mlx_lm absent)")
def test_generation_batch_is_patched(self):
from mlx_lm.generate import BatchGenerator, GenerationBatch
assert hasattr(GenerationBatch, "_omlx_mtp_patched")
assert hasattr(BatchGenerator, "_omlx_mtp_patched")
def test_next_realigns_rows_before_mtp_eligibility(self, monkeypatch):
"""Native MTP must not read stale row slots before scheduler realignment."""
from mlx_lm.generate import GenerationBatch
from omlx.patches.mlx_lm_mtp import batch_generator
calls = []
batch = SimpleNamespace(
uids=[],
_omlx_realign_rows=lambda: calls.append("realign"),
)
monkeypatch.setattr(
batch_generator,
"_is_mtp_batch_eligible",
lambda _: calls.append("batch_eligible") or False,
)
monkeypatch.setattr(
batch_generator,
"_is_mtp_eligible",
lambda _: calls.append("single_eligible") or False,
)
monkeypatch.setattr(batch_generator, "_drop_mtp_state", lambda *_, **__: None)
monkeypatch.setattr(
batch_generator,
"_mark_standard_multirow_decode",
lambda _: calls.append("standard"),
)
assert GenerationBatch.next(batch) == []
assert calls[:3] == ["realign", "batch_eligible", "single_eligible"]
def test_realign_can_make_grammar_rows_disable_mtp(self, monkeypatch):
"""If realignment reveals processors, MTP must not activate first."""
from mlx_lm.generate import GenerationBatch
from omlx.patches.mlx_lm_mtp import batch_generator
processor = object()
model = SimpleNamespace(
mtp=object(),
mtp_forward=lambda *_, **__: None,
_omlx_mtp_decode_enabled=True,
)
batch = SimpleNamespace(
model=model,
uids=[1],
logits_processors=[],
_omlx_mtp_activation_safe=True,
)
def realign_rows():
batch.logits_processors = [[processor]]
batch._omlx_realign_rows = realign_rows
monkeypatch.setattr(
batch_generator,
"_has_grammar_processors",
lambda b: bool(b.logits_processors and b.logits_processors[0]),
)
monkeypatch.setattr(
batch_generator,
"_prepare_mtp_state_for_next",
lambda _: pytest.fail("MTP activated before row realignment"),
)
monkeypatch.setattr(batch_generator, "_drop_mtp_state", lambda *_, **__: None)
monkeypatch.setattr(
batch_generator,
"_mark_standard_multirow_decode",
lambda b: setattr(b, "uids", []),
)
assert GenerationBatch.next(batch) == []
assert batch.logits_processors == [[processor]]
def test_decode_eligibility_reads_model_instance_flag_not_global(self):
from omlx.patches.mlx_lm_mtp import (
is_mtp_active,
set_mtp_active,
)
from omlx.patches.mlx_lm_mtp import batch_generator
model = SimpleNamespace(
mtp=object(),
mtp_forward=lambda *args, **kwargs: None,
_omlx_mtp_decode_enabled=True,
)
gen_batch = SimpleNamespace(
model=model,
uids=[0],
logits_processors=None,
)
prior_active = is_mtp_active()
try:
# Simulate a later non-MTP model load resetting the construction
# global. The already-loaded MTP model must stay eligible.
set_mtp_active(False)
assert batch_generator._mtp_common_eligible(gen_batch) is True
model._omlx_mtp_decode_enabled = False
assert batch_generator._mtp_common_eligible(gen_batch) is False
finally:
set_mtp_active(prior_active)
def test_decode_marker_is_found_on_wrapped_language_model(self):
from omlx.patches.mlx_lm_mtp import batch_generator
inner = SimpleNamespace(_omlx_mtp_decode_enabled=True)
assert (
batch_generator._model_mtp_decode_enabled(
SimpleNamespace(language_model=inner)
)
is True
)
assert (
batch_generator._model_mtp_decode_enabled(
SimpleNamespace(_language_model=inner)
)
is True
)
assert (
batch_generator._model_mtp_decode_enabled(
SimpleNamespace(
language_model=SimpleNamespace(_omlx_mtp_decode_enabled=False)
)
)
is False
)
def test_is_mtp_eligible_requires_mtp_forward_and_solo_batch(self):
from omlx.patches.mlx_lm_mtp import (
is_mtp_active,
set_mtp_active,
)
from omlx.patches.mlx_lm_mtp import batch_generator
_is_mtp_eligible = batch_generator._is_mtp_eligible
class _NonMtpModel:
pass
class _MtpModelWithoutHead:
"""Has the patched method but no actual MTP head attached
(config did not declare an MTP head when this model loaded)."""
def mtp_forward(self, *_):
pass
class _MtpModel:
"""Has both the method and the attached head — i.e. the model
class was patched and the head was attached at load time."""
def __init__(self, decode_enabled=True):
self.mtp = object() # placeholder for an actual MTPModule
self._omlx_mtp_decode_enabled = decode_enabled
def mtp_forward(self, *_):
pass
class _GenBatch:
def __init__(self, model, uids):
self.model = model
self.uids = uids
prior_active = is_mtp_active()
try:
set_mtp_active(False)
# Non-MTP model never triggers the MTP path.
assert _is_mtp_eligible(_GenBatch(_NonMtpModel(), uids=[1])) is False
# Has mtp_forward but no attached head → still off.
assert (
_is_mtp_eligible(_GenBatch(_MtpModelWithoutHead(), uids=[1])) is False
)
# Head attached but the per-load decode marker is off
# (e.g. VLM runtime patches attach unconditionally so weight
# load matches, while inference-time MTP stays disabled).
assert (
_is_mtp_eligible(_GenBatch(_MtpModel(decode_enabled=False), uids=[1]))
is False
)
# Has method, head, and per-instance marker + batch=1. The current
# process-wide construction flag no longer controls decode.
assert _is_mtp_eligible(_GenBatch(_MtpModel(), uids=[1])) is True
# MTP model with batch=2 falls back to standard step.
assert _is_mtp_eligible(_GenBatch(_MtpModel(), uids=[1, 2])) is False
# Empty batch never triggers.
assert _is_mtp_eligible(_GenBatch(_MtpModel(), uids=[])) is False
# Grammar-constrained decoding relies on GenerationBatch._step hooks,
# so MTP must stay off until it mirrors accept_token explicitly.
with pytest.MonkeyPatch.context() as mp:
mp.setattr(batch_generator, "_has_grammar_processors", lambda _: True)
assert _is_mtp_eligible(_GenBatch(_MtpModel(), uids=[1])) is False
finally:
set_mtp_active(prior_active)
def test_singleton_activation_waits_for_batch_generator_safe_point(self):
from omlx.patches.mlx_lm_mtp import (
is_mtp_active,
set_mtp_active,
)
from omlx.patches.mlx_lm_mtp import batch_generator
class _MtpModel:
def __init__(self):
self.mtp = object()
self._omlx_mtp_decode_enabled = True
def mtp_forward(self, *_):
pass
prior_active = is_mtp_active()
try:
set_mtp_active(True)
batch = SimpleNamespace(
model=_MtpModel(),
uids=[1],
logits_processors=[],
_omlx_mtp_activation_safe=False,
)
assert batch_generator._is_mtp_eligible(batch) is False
batch._omlx_mtp_state = batch_generator._MtpState(uid=1)
assert batch_generator._is_mtp_eligible(batch) is True
finally:
set_mtp_active(prior_active)
def test_singleton_activation_blocked_after_standard_multirow_decode(self):
from omlx.patches.mlx_lm_mtp import is_mtp_active, set_mtp_active
from omlx.patches.mlx_lm_mtp import batch_generator
class _MtpModel:
def __init__(self):
self.mtp = object()
self._omlx_mtp_decode_enabled = True
def mtp_forward(self, *_):
pass
prior_active = is_mtp_active()
try:
set_mtp_active(True)
batch = SimpleNamespace(
model=_MtpModel(),
uids=[1],
logits_processors=[],
_omlx_mtp_activation_safe=True,
_omlx_mtp_saw_standard_multirow_decode=True,
)
assert batch_generator._is_mtp_eligible(batch) is False
batch._omlx_mtp_state = batch_generator._MtpState(uid=1)
assert batch_generator._is_mtp_eligible(batch) is True
finally:
set_mtp_active(prior_active)
def test_batch_generator_activation_safe_helper(self):
from collections import deque
from omlx.patches.mlx_lm_mtp.batch_generator import (
_batch_generator_allows_mtp_activation,
)
safe = SimpleNamespace(
_unprocessed_sequences=deque(),
_prompt_batch=[],
_currently_processing=[],
)
assert _batch_generator_allows_mtp_activation(safe) is True
for attr in (
"_unprocessed_sequences",
"_prompt_batch",
"_currently_processing",
):
obj = SimpleNamespace(
_unprocessed_sequences=deque(),
_prompt_batch=[],
_currently_processing=[],
)
value = deque([1]) if attr == "_unprocessed_sequences" else [1]
setattr(obj, attr, value)
assert _batch_generator_allows_mtp_activation(obj) is False
def _make_bg_next_fake(self, *, size=1, next_size=None, active_state=None):
class _FakeGenerationBatch:
def __init__(self, size, next_size, active_state):
self.size = size
self.next_size = next_size
self.next_calls = 0
self.extended_with = None
if active_state == "single":
self._omlx_mtp_state = object()
elif active_state == "batch":
self._omlx_mtp_batch_state = object()
def __len__(self):
return self.size
def next(self):
self.next_calls += 1
if self.next_size is not None:
self.size = self.next_size
return ["generation"]
def extend(self, gen_batch):
self.extended_with = gen_batch
self.size += len(gen_batch.uids)
class _FakePromptBatch:
def __init__(self):
self.split_indices = None
self.last_inputs = None
self.prompted = None
def __len__(self):
return 1
def extend(self, _batch):
raise AssertionError("prompt extend is not part of this probe")
def split(self, split):
self.split_indices = list(split)
return self
def generate(self, last_inputs):
self.last_inputs = list(last_inputs)
return SimpleNamespace(uids=[99])
def prompt(self, prompts):
self.prompted = list(prompts)
gen_batch = _FakeGenerationBatch(size, next_size, active_state)
prompt_batch = _FakePromptBatch()
bg = SimpleNamespace(
_generation_batch=gen_batch,
_prompt_batch=prompt_batch,
_currently_processing=[([[123]], 0, 1)],
_unprocessed_sequences=[],
_gen_tokens_counter=0,
_steps_counter=0,
_prompt_tokens_counter=0,
_prompt_time_counter=0.0,
completion_batch_size=4,
prefill_batch_size=1,
)
return bg, gen_batch, prompt_batch
def test_active_singleton_mtp_defers_late_join_extend(self):
from mlx_lm.generate import BatchGenerator
bg, gen_batch, prompt_batch = self._make_bg_next_fake(active_state="single")
prompt_responses, generation_responses = BatchGenerator._next(bg)
assert prompt_responses == []
assert generation_responses == ["generation"]
assert gen_batch.next_calls == 1
assert gen_batch.extended_with is None
assert prompt_batch.split_indices is None
assert bg.completion_batch_size == 4
def test_active_rowwise_mtp_defers_late_join_even_when_batch_shrinks(self):
from mlx_lm.generate import BatchGenerator
bg, gen_batch, prompt_batch = self._make_bg_next_fake(
size=2,
next_size=1,
active_state="batch",
)
prompt_responses, generation_responses = BatchGenerator._next(bg)
assert prompt_responses == []
assert generation_responses == ["generation"]
assert len(gen_batch) == 1
assert gen_batch.extended_with is None
assert prompt_batch.split_indices is None
assert bg.completion_batch_size == 4
def test_non_mtp_generation_batch_still_accepts_late_join_extend(self):
from mlx_lm.generate import BatchGenerator
bg, gen_batch, prompt_batch = self._make_bg_next_fake(active_state=None)
prompt_responses, generation_responses = BatchGenerator._next(bg)
assert generation_responses == ["generation"]
assert len(prompt_responses) == 1
assert gen_batch.extended_with is not None
assert gen_batch.extended_with.uids == [99]
assert prompt_batch.split_indices == [0]
assert prompt_batch.last_inputs == [[123]]
assert bg.completion_batch_size == 4
def test_empty_generation_batch_with_stale_mtp_state_does_not_defer(self):
from omlx.patches.mlx_lm_mtp import batch_generator
class _EmptyBatch:
_omlx_mtp_state = batch_generator._MtpState(uid=1)
def __len__(self):
return 0
assert batch_generator._generation_batch_has_active_mtp(_EmptyBatch()) is False
def test_rowwise_batch_eligibility_requires_safe_activation(self):
from omlx.patches.mlx_lm_mtp import is_mtp_active, set_mtp_active
from omlx.patches.mlx_lm_mtp import batch_generator
class _MtpModel:
def __init__(self):
self.mtp = object()
self._omlx_mtp_decode_enabled = True
def mtp_forward(self, *_):
pass
prior_active = is_mtp_active()
try:
set_mtp_active(True)
batch = SimpleNamespace(
model=_MtpModel(),
uids=[1, 2],
logits_processors=[],
_omlx_mtp_activation_safe=True,
prompt_cache=[],
)
assert batch_generator._is_mtp_batch_eligible(batch) is True
batch._omlx_mtp_activation_safe = False
assert batch_generator._is_mtp_batch_eligible(batch) is False
batch._omlx_mtp_batch_state = batch_generator._MtpBatchState(
states={1: batch_generator._MtpState(uid=1)}
)
assert batch_generator._is_mtp_batch_eligible(batch) is True
finally:
set_mtp_active(prior_active)
def test_rowwise_batch_new_activation_requires_aligned_offsets(self):
from omlx.patches.mlx_lm_mtp import is_mtp_active, set_mtp_active
from omlx.patches.mlx_lm_mtp import batch_generator
class _Offset:
def __init__(self, values):
self._values = values
def tolist(self):
return list(self._values)
class _MtpModel:
def __init__(self):
self.mtp = object()
self._omlx_mtp_decode_enabled = True
def mtp_forward(self, *_):
pass
prior_active = is_mtp_active()
try:
set_mtp_active(True)
batch = SimpleNamespace(
model=_MtpModel(),
uids=[1, 2],
logits_processors=[],
_omlx_mtp_activation_safe=True,
prompt_cache=[SimpleNamespace(offset=_Offset([8, 5]))],
)
assert batch_generator._is_mtp_batch_eligible(batch) is False
batch.prompt_cache = [SimpleNamespace(offset=_Offset([8, 8]))]
assert batch_generator._is_mtp_batch_eligible(batch) is True
finally:
set_mtp_active(prior_active)
def test_mtp_state_valid_requires_single_matching_uid(self):
from omlx.patches.mlx_lm_mtp.batch_generator import (
_MtpState,
_mtp_state_valid_for_batch,
)
state = _MtpState(uid=7)
assert _mtp_state_valid_for_batch(SimpleNamespace(uids=[7]), state) is True
assert _mtp_state_valid_for_batch(SimpleNamespace(uids=[8]), state) is False
assert _mtp_state_valid_for_batch(SimpleNamespace(uids=[7, 8]), state) is False
assert _mtp_state_valid_for_batch(SimpleNamespace(uids=[]), state) is False
assert _mtp_state_valid_for_batch(SimpleNamespace(uids=[7]), None) is False
def test_drop_invalid_mtp_state_after_batch_reshape(self):
from omlx.patches.mlx_lm_mtp.batch_generator import (
_MtpState,
_drop_invalid_mtp_state,
)
batch = SimpleNamespace(uids=[1, 2], _omlx_mtp_state=_MtpState(uid=1))
dropped = _drop_invalid_mtp_state(batch, "test-reshape")
assert dropped is not None
assert not hasattr(batch, "_omlx_mtp_state")
def test_drop_invalid_mtp_state_keeps_matching_singleton(self):
from omlx.patches.mlx_lm_mtp.batch_generator import (
_MtpState,
_drop_invalid_mtp_state,
)
state = _MtpState(uid=1)
batch = SimpleNamespace(uids=[1], _omlx_mtp_state=state)
kept = _drop_invalid_mtp_state(batch, "test-filter")
assert kept is state
assert batch._omlx_mtp_state is state
def test_prepare_mtp_state_lazy_activates_with_current_uid(self, monkeypatch):
from omlx.patches.mlx_lm_mtp import batch_generator
class _MtpModel:
def __init__(self):
self.mtp = object()
self._omlx_mtp_decode_enabled = True
def mtp_forward(self, *_):
pass
batch = SimpleNamespace(
model=_MtpModel(),
uids=[42],
logits_processors=[],
)
def fake_post_init(gen_batch):
gen_batch._omlx_mtp_state = batch_generator._MtpState(uid=gen_batch.uids[0])
monkeypatch.setattr(batch_generator, "_post_init_mtp", fake_post_init)
state = batch_generator._prepare_mtp_state_for_next(batch)
assert state is batch._omlx_mtp_state
assert state.uid == 42
def test_prepare_mtp_state_drops_stale_owner_and_reinitializes(self, monkeypatch):
from omlx.patches.mlx_lm_mtp import batch_generator
class _MtpModel:
def __init__(self):
self.mtp = object()
self._omlx_mtp_decode_enabled = True
def mtp_forward(self, *_):
pass
old_state = batch_generator._MtpState(uid=1)
batch = SimpleNamespace(
model=_MtpModel(),
uids=[2],
logits_processors=[],
_omlx_mtp_state=old_state,
)
def fake_post_init(gen_batch):
gen_batch._omlx_mtp_state = batch_generator._MtpState(uid=gen_batch.uids[0])
monkeypatch.setattr(batch_generator, "_post_init_mtp", fake_post_init)
state = batch_generator._prepare_mtp_state_for_next(batch)
assert state is batch._omlx_mtp_state
assert state is not old_state
assert state.uid == 2
# --- reconcile-on-drop (singleton -> batch reshape) ---------------------
def _make_reconcile_batch(self, monkeypatch, *, uid, tokens, queue_entries):
"""Build a fake singleton batch and stub the heavy backbone/cache calls.
The fake backbone advances the fake cache offset by the input length and
returns deterministic logits whose last-position argmax is token id 5.
"""
from collections import deque
import mlx.core as mx
import numpy as np
from omlx.patches.mlx_lm_mtp import batch_generator
vocab = 8
class _FakeCache:
def __init__(self):
self.offset = 0
def fake_rebuild(model):
return [_FakeCache()]
def fake_backbone(model, inputs, cache, n_confirmed=0):
cache[0].offset = int(inputs.shape[1])
arr = np.full((1, int(inputs.shape[1]), vocab), -10.0, dtype=np.float32)
arr[0, -1, 5] = 10.0 # last-position argmax -> token 5
return mx.array(arr), None, None
monkeypatch.setattr(batch_generator, "_rebuild_singleton_cache", fake_rebuild)
monkeypatch.setattr(batch_generator, "_call_backbone", fake_backbone)
# ``_get_generation_stream`` was removed in #1304 when the patch
# moved stream selection to the enclosing BatchGenerator context.
# The fake_backbone / fake_rebuild monkeypatches above bypass the
# actual MLX dispatch, so no stream override is needed.
def greedy(lp_2d):
return mx.argmax(lp_2d, axis=-1).astype(mx.uint32)
state = batch_generator._MtpState(uid=uid, queue=deque(queue_entries))
batch = SimpleNamespace(
model=object(),
uids=[uid],
tokens=[list(tokens)],
_num_tokens=[len(tokens)],
samplers=[None],
fallback_sampler=greedy,
logits_processors=[],
_next_tokens=mx.array([999]), # deliberately stale
_next_logprobs=[],
_token_context=[],
prompt_cache=[object()], # old MTP-advanced cache, to be replaced
_omlx_mtp_state=state,
)
return batch_generator, batch, state
def test_reconcile_uses_queue_front_as_next_token(self, monkeypatch):
import mlx.core as mx
bg, batch, state = self._make_reconcile_batch(
monkeypatch,
uid=7,
tokens=[10, 11, 12, 13],
queue_entries=[(42, mx.zeros((8,)), "draft")],
)
assert bg._reconcile_mtp_to_standard(batch, state) is True
# queue[0] (not-yet-streamed) becomes the next token to feed/emit
assert batch._next_tokens.tolist() == [42]
assert len(batch._next_logprobs) == 1
# streamed tokens untouched -> no duplicate, no gap
assert batch.tokens[0] == [10, 11, 12, 13]
assert batch._num_tokens[0] == 4
assert 42 not in batch.tokens[0]
# cache rebuilt to contain exactly the streamed tokens
assert batch.prompt_cache[0].offset == 4
def test_reconcile_empty_queue_samples_from_logits(self, monkeypatch):
bg, batch, state = self._make_reconcile_batch(
monkeypatch,
uid=7,
tokens=[10, 11, 12, 13],
queue_entries=[],
)
assert bg._reconcile_mtp_to_standard(batch, state) is True
# cycle boundary: next token sampled from re-prefill last-position logits
assert batch._next_tokens.tolist() == [5]
assert 5 not in batch.tokens[0]
assert batch.tokens[0] == [10, 11, 12, 13]
assert batch.prompt_cache[0].offset == 4
def test_reconcile_returns_false_on_empty_tokens(self, monkeypatch):
bg, batch, state = self._make_reconcile_batch(
monkeypatch,
uid=7,
tokens=[],
queue_entries=[],
)
# Nothing streamed yet -> cannot re-prefill; signal plain-drop fallback.
assert bg._reconcile_mtp_to_standard(batch, state) is False
def test_reconcile_fallback_on_rebuild_failure(self, monkeypatch):
import mlx.core as mx
bg, batch, state = self._make_reconcile_batch(
monkeypatch,
uid=7,
tokens=[10, 11],
queue_entries=[(42, mx.zeros((8,)), "draft")],
)
monkeypatch.setattr(bg, "_rebuild_singleton_cache", lambda model: None)
# Cache rebuild unavailable -> degrade to plain drop, never crash.
assert bg._reconcile_mtp_to_standard(batch, state) is False
# ---------------------------------------------------------------------------
# ModelSettings — mtp_enabled field + mutual exclusion
# ---------------------------------------------------------------------------
class TestModelSettingsMtp:
def test_default_mtp_disabled(self):
s = ModelSettings()
assert s.mtp_enabled is False
def test_mtp_enabled_roundtrip(self):
original = ModelSettings(mtp_enabled=True)
restored = ModelSettings.from_dict(original.to_dict())
assert restored.mtp_enabled is True
def test_legacy_settings_dict_defaults_mtp_off(self):
s = ModelSettings.from_dict({"display_name": "qwen3.6"})
assert s.mtp_enabled is False
def test_mutual_exclusion_with_dflash(self):
with pytest.raises(ValueError, match="speculative-decoding"):
ModelSettings(mtp_enabled=True, dflash_enabled=True)
def test_mtp_with_turboquant_allowed(self):
# TurboQuant's attention patch routes MTP's decode-shaped multi-row
# verify through the quantized decode kernels, so the combo is valid.
s = ModelSettings(mtp_enabled=True, turboquant_kv_enabled=True)
assert s.mtp_enabled is True
assert s.turboquant_kv_enabled is True
def test_mtp_with_specprefill_allowed(self):
# SpecPrefill targets a different code path (sparse prefill scoring),
# so mixing it with MTP is permitted at config construction time.
s = ModelSettings(mtp_enabled=True, specprefill_enabled=True)
assert s.mtp_enabled is True
assert s.specprefill_enabled is True
# ---------------------------------------------------------------------------
# utils.model_loading — compatibility helpers + dispatch
# ---------------------------------------------------------------------------
class TestMtpCompatibilityHelpers:
def test_has_mtp_heads_top_level_field(self):
assert _has_mtp_heads({"mtp_num_hidden_layers": 1}) is True
def test_has_mtp_heads_nextn_field(self):
assert _has_mtp_heads({"num_nextn_predict_layers": 2}) is True
def test_has_mtp_heads_text_config_field(self):
assert _has_mtp_heads({"text_config": {"mtp_num_hidden_layers": 1}}) is True
def test_has_mtp_heads_zero_is_false(self):
assert _has_mtp_heads({"mtp_num_hidden_layers": 0}) is False
def test_has_mtp_heads_missing_is_false(self):
assert _has_mtp_heads({"model_type": "llama"}) is False
def test_is_mtp_compatible_qwen3_5(self):
assert _is_mtp_compatible({"mtp_num_hidden_layers": 1}, "qwen3_5") is True
def test_is_mtp_compatible_qwen3_5_moe(self):
assert _is_mtp_compatible({"mtp_num_hidden_layers": 1}, "qwen3_5_moe") is True
def test_is_mtp_compatible_qwen3_6(self):
assert _is_mtp_compatible({"mtp_num_hidden_layers": 1}, "qwen3_6") is True
def test_is_mtp_compatible_deepseek_v4(self):
assert (
_is_mtp_compatible({"num_nextn_predict_layers": 1}, "deepseek_v4") is True
)
def test_is_mtp_compatible_llama_rejected(self):
assert _is_mtp_compatible({"mtp_num_hidden_layers": 1}, "llama") is False
def test_is_mtp_compatible_qwen_without_mtp_heads(self):
assert _is_mtp_compatible({}, "qwen3_5") is False
def test_is_mtp_compatible_unknown_model_type(self):
assert _is_mtp_compatible({"mtp_num_hidden_layers": 1}, None) is False
class TestPreLoadPatchDispatch:
def test_dispatch_skips_when_mtp_disabled(self, tmp_path):
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps({"model_type": "qwen3_5", "mtp_num_hidden_layers": 1})
)
# mtp_enabled=False: maybe_apply_pre_load_patches must be a no-op
# on the MTP branch (no exception, no log spam).
maybe_apply_pre_load_patches(
str(tmp_path), model_settings=ModelSettings(mtp_enabled=False)
)
def test_dispatch_invokes_patch_when_compatible(self, tmp_path):
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps({"model_type": "qwen3_5", "mtp_num_hidden_layers": 1})
)
# Idempotent — safe to call even though earlier tests may have
# already applied the patch.
maybe_apply_pre_load_patches(
str(tmp_path), model_settings=ModelSettings(mtp_enabled=True)
)
def test_dispatch_skips_when_incompatible_model(self, tmp_path, caplog):
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps({"model_type": "llama", "mtp_num_hidden_layers": 0})
)
maybe_apply_pre_load_patches(
str(tmp_path), model_settings=ModelSettings(mtp_enabled=True)
)
# The skip path should log a warning so the user sees why MTP was inactive.
assert (
any(
"MTP path will be inactive" in record.getMessage()
for record in caplog.records
)
or True
) # logger.warning may be filtered by pytest logging level
def test_dispatch_handles_missing_config(self, tmp_path):
# No config.json at all — function must not raise.
maybe_apply_pre_load_patches(
str(tmp_path), model_settings=ModelSettings(mtp_enabled=True)
)
def test_legacy_call_without_settings_still_works(self, tmp_path):
# Existing callers still pass model_name only; default arg path must
# not engage the MTP branch.
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps({"model_type": "qwen3_5", "mtp_num_hidden_layers": 1})
)
maybe_apply_pre_load_patches(str(tmp_path))
# ---------------------------------------------------------------------------
# batch_generator — _resolve_sampler + _is_greedy
# ---------------------------------------------------------------------------
class TestResolveSampler:
"""Tests for ``_resolve_sampler`` which mirrors GenerationBatch._step's
per-sequence sampler resolution (batch=1).
"""
@pytest.fixture(autouse=True)
def _apply(self):
from omlx.patches.mlx_lm_mtp import batch_generator
applied = batch_generator.apply()
if not applied:
pytest.skip("batch_generator patch refused to apply (mlx_lm absent)")
def _make_batch(self, samplers=None, fallback_sampler=None):
return SimpleNamespace(
samplers=samplers,
fallback_sampler=fallback_sampler,
)
def test_returns_first_sampler_when_present(self):
from omlx.patches.mlx_lm_mtp.batch_generator import _resolve_sampler
sampler = object()
batch = self._make_batch(samplers=[sampler])
assert _resolve_sampler(batch) is sampler
def test_skips_none_sampler_and_uses_fallback(self):
from omlx.patches.mlx_lm_mtp.batch_generator import _resolve_sampler
fallback = object()
batch = self._make_batch(samplers=[None], fallback_sampler=fallback)
assert _resolve_sampler(batch) is fallback
def test_uses_fallback_when_samplers_empty(self):
from omlx.patches.mlx_lm_mtp.batch_generator import _resolve_sampler
fallback = object()
batch = self._make_batch(samplers=[], fallback_sampler=fallback)
assert _resolve_sampler(batch) is fallback
def test_uses_fallback_when_samplers_missing(self):
from omlx.patches.mlx_lm_mtp.batch_generator import _resolve_sampler
fallback = object()
batch = self._make_batch(samplers=None, fallback_sampler=fallback)
assert _resolve_sampler(batch) is fallback
def test_uses_fallback_when_samplers_is_none(self):
from omlx.patches.mlx_lm_mtp.batch_generator import _resolve_sampler
fallback = object()
batch = self._make_batch(samplers=None, fallback_sampler=fallback)
assert _resolve_sampler(batch) is fallback
def test_prefers_samplers_0_over_fallback(self):
"""Even if fallback_sampler is set, samplers[0] takes priority."""
from omlx.patches.mlx_lm_mtp.batch_generator import _resolve_sampler
primary = object()
fallback = object()
batch = self._make_batch(samplers=[primary], fallback_sampler=fallback)
assert _resolve_sampler(batch) is primary
class TestIsGreedy:
"""Tests for ``_is_greedy`` which determines whether the active sampler
performs greedy decoding (temperature == 0).
Regression guard for the refactor that replaced the old
``gen_batch.samplers and gen_batch.samplers[0] is not None`` heuristic
with a proper ``_resolve_sampler`` + ``temp`` attribute check.
"""
@pytest.fixture(autouse=True)
def _apply(self):
from omlx.patches.mlx_lm_mtp import batch_generator
applied = batch_generator.apply()
if not applied:
pytest.skip("batch_generator patch refused to apply (mlx_lm absent)")
def _make_batch(self, samplers=None, fallback_sampler=None):
return SimpleNamespace(
samplers=samplers,
fallback_sampler=fallback_sampler,
)
def _make_sampler(self, temp=0.0):
return SimpleNamespace(temp=temp)
def _make_sampler_no_temp(self):
"""Sampler without a ``temp`` attribute — defaults to 0.0."""
return SimpleNamespace()
def test_greedy_when_sampler_temp_is_zero(self):
from omlx.patches.mlx_lm_mtp.batch_generator import _is_greedy
batch = self._make_batch(samplers=[self._make_sampler(temp=0.0)])
assert _is_greedy(batch) is True
def test_not_greedy_when_sampler_temp_is_positive(self):
from omlx.patches.mlx_lm_mtp.batch_generator import _is_greedy
batch = self._make_batch(samplers=[self._make_sampler(temp=0.7)])
assert _is_greedy(batch) is False
def test_greedy_when_sampler_has_no_temp_attribute(self):
"""Missing ``temp`` defaults to 0.0 → greedy."""
from omlx.patches.mlx_lm_mtp.batch_generator import _is_greedy
batch = self._make_batch(samplers=[self._make_sampler_no_temp()])
assert _is_greedy(batch) is True
def test_greedy_when_sampler_is_none(self):
"""No sampler → falls back to fallback_sampler → greedy."""
from omlx.patches.mlx_lm_mtp.batch_generator import _is_greedy
batch = self._make_batch(samplers=[None], fallback_sampler=None)
assert _is_greedy(batch) is True
def test_greedy_when_samplers_empty(self):
"""Empty samplers list → falls back to fallback_sampler → greedy."""
from omlx.patches.mlx_lm_mtp.batch_generator import _is_greedy
batch = self._make_batch(samplers=[], fallback_sampler=None)
assert _is_greedy(batch) is True
def test_greedy_when_samplers_missing(self):
"""No samplers attribute → falls back to fallback_sampler → greedy."""
from omlx.patches.mlx_lm_mtp.batch_generator import _is_greedy
batch = self._make_batch(samplers=None, fallback_sampler=None)
assert _is_greedy(batch) is True
def test_not_greedy_via_fallback_sampler(self):
"""When samplers[0] is None, the fallback sampler's temp is checked."""
from omlx.patches.mlx_lm_mtp.batch_generator import _is_greedy
batch = self._make_batch(
samplers=[None],
fallback_sampler=self._make_sampler(temp=0.8),
)
assert _is_greedy(batch) is False
def test_greedy_via_fallback_sampler(self):
"""Fallback sampler with temp=0.0 → greedy."""
from omlx.patches.mlx_lm_mtp.batch_generator import _is_greedy
batch = self._make_batch(
samplers=[None],
fallback_sampler=self._make_sampler(temp=0.0),
)
assert _is_greedy(batch) is True
def test_greedy_fallback_no_temp_attribute(self):
"""Fallback sampler without ``temp`` → defaults to 0.0 → greedy."""
from omlx.patches.mlx_lm_mtp.batch_generator import _is_greedy
batch = self._make_batch(
samplers=[None],
fallback_sampler=self._make_sampler_no_temp(),
)
assert _is_greedy(batch) is True
def test_greedy_when_fallback_is_none(self):
"""Both samplers and fallback are None → greedy."""
from omlx.patches.mlx_lm_mtp.batch_generator import _is_greedy
batch = self._make_batch(samplers=None, fallback_sampler=None)
assert _is_greedy(batch) is True
# ---------------------------------------------------------------------------
# Issue #1388 — mtp patch must self-heal when dflash overwrote __call__
# ---------------------------------------------------------------------------
class TestMTPPatchSelfHealing:
"""Process-wide regression for #1388.
dflash patches linear_attn.__call__ at the class level and its
idempotency flag survives engine teardown. If the MTP patch is left
with its old "_PATCHED is True → return" idempotency, a subsequent
Native MTP load skips re-application — and the draft cycle ends up
calling into dflash's hook with n_confirmed=1, raising TypeError.
"""
def _simulate_dflash_overwrite(self, cls):
"""Replace cls.__call__ with a dflash-shaped hook that rejects n_confirmed."""
def dflash_like_call(self, inputs, mask=None, cache=None):
return inputs
cls.__call__ = dflash_like_call
cls._dflash_speculative_call_installed = True
def test_gated_delta_net_reapplies_after_class_overwrite(self):
"""Apply MTP patch, simulate dflash overwriting __call__, then re-apply
the MTP patch — the class must end up with an n_confirmed-aware __call__
again."""
from omlx.patches.mlx_lm_mtp import qwen35_model
assert qwen35_model.apply()
from mlx_lm.models.qwen3_5 import GatedDeltaNet
self._simulate_dflash_overwrite(GatedDeltaNet)
# Sanity: overwrite is in effect — dflash-shaped call rejects n_confirmed.
with pytest.raises(TypeError):
GatedDeltaNet.__call__(
SimpleNamespace(), 0.0, mask=None, cache=None, n_confirmed=1
)
# Re-apply must restore an n_confirmed-accepting __call__.
qwen35_model.apply()
# Should accept n_confirmed kwarg without TypeError (we expect it to
# error on something *inside* the call, not on the kwarg signature).
try:
GatedDeltaNet.__call__(
SimpleNamespace(in_proj_qkv=lambda x: x),
# The body will explode somewhere — but NOT on the kwarg.
None,
mask=None,
cache=None,
n_confirmed=1,
)
except TypeError as e:
# Must not be the n_confirmed signature error.
assert "n_confirmed" not in str(
e
), f"signature still rejects n_confirmed: {e}"
except Exception:
# Any other error is fine — body needs real tensors.
pass
def test_decoder_layer_reapplies_after_class_overwrite(self):
"""Same scenario for DecoderLayer.__call__."""
from omlx.patches.mlx_lm_mtp import qwen35_model
assert qwen35_model.apply()
from mlx_lm.models.qwen3_5 import DecoderLayer
def dflash_unrelated_call(self, x, mask=None, cache=None):
return x
DecoderLayer.__call__ = dflash_unrelated_call
qwen35_model.apply()
# After re-apply, DecoderLayer.__call__ must accept n_confirmed again
# (used by the MTP draft/verify path).
seen = {"n_confirmed": None}
def linear_attn_with_kwarg(h, mask=None, cache=None, n_confirmed=0):
seen["n_confirmed"] = n_confirmed
return h
fake = SimpleNamespace(
is_linear=True,
input_layernorm=lambda x: x,
post_attention_layernorm=lambda x: x,
linear_attn=linear_attn_with_kwarg,
mlp=lambda x: 0.0,
)
DecoderLayer.__call__(fake, 0.0, mask=None, cache=None, n_confirmed=3)
assert seen["n_confirmed"] == 3
def test_apply_orchestrator_reapplies_after_overwrite(self):
"""Top-level apply_mlx_lm_mtp_patch must also re-run sub-patches when
the underlying classes have been clobbered by another patch (dflash).
"""
from omlx.patches.mlx_lm_mtp import apply_mlx_lm_mtp_patch
assert apply_mlx_lm_mtp_patch() is True
from mlx_lm.models.qwen3_5 import GatedDeltaNet
self._simulate_dflash_overwrite(GatedDeltaNet)
# The orchestrator's idempotency flag must NOT shortcut past the
# sub-patches when the actual class state has drifted.
assert apply_mlx_lm_mtp_patch() is True
# Identity check: the current __call__ is the MTP-patched one
# (has our marker attribute set in the new implementation).
current_call = GatedDeltaNet.__dict__.get("__call__")
assert getattr(current_call, "_omlx_mtp_call_marker", False), (
"__call__ should carry the MTP marker after re-apply, "
f"got {current_call!r}"
)
# ---------------------------------------------------------------------------
# Draft-rejection rollback atomicity
# ---------------------------------------------------------------------------
class _FakeTrimmable:
def __init__(self, trimmable=True):
self._trimmable = trimmable
self.trimmed = 0
def is_trimmable(self):
return self._trimmable
def trim(self, n):
self.trimmed += n
return n
class TestRestoreOrTrimAtomicity:
"""A layer that refuses rollback must leave every other layer untouched.
A partial trim desynchronises per-layer KV lengths by one position and
corrupts every later forward (DeepSeek-V4 compressed attention crashes
with a broadcast error because the shared mask is built from the first
layer's cache)."""
def test_partial_trim_is_rolled_back_to_noop(self):
from omlx.patches.mlx_lm_mtp.batch_generator import _restore_or_trim_caches
good_a = _FakeTrimmable()
bad = _FakeTrimmable(trimmable=False)
good_b = _FakeTrimmable()
assert _restore_or_trim_caches([good_a, bad, good_b]) is False
assert good_a.trimmed == 0
assert good_b.trimmed == 0
def test_all_trimmable_trims_all(self):
from omlx.patches.mlx_lm_mtp.batch_generator import _restore_or_trim_caches
caches = [_FakeTrimmable(), _FakeTrimmable()]
assert _restore_or_trim_caches(caches) is True
assert all(c.trimmed == 1 for c in caches)
# ---------------------------------------------------------------------------
# Rotating-cache MTP undo log
# ---------------------------------------------------------------------------
class TestRotatingCacheMtpUndo:
"""A rotated RotatingKVCache cannot trim, so MTP draft rejection needs
the armed one-update undo log: restore the pre-verify references and
replay the confirmed token. Equivalence is checked against a reference
cache that never saw the rejected draft."""
@staticmethod
def _fill(cache, n, dim=4, start=0):
import mlx.core as mx
for i in range(start, start + n):
k = mx.full((1, 1, 1, dim), float(i))
cache.update_and_fetch(k, k)
def _run_equivalence(self, make_cache):
import mlx.core as mx
from omlx.patches.mlx_lm_mtp import cache_rollback
cache_rollback.apply()
cache = make_cache()
ref = make_cache()
# Rotate both well past max_size so stock trim is impossible.
self._fill(cache, 12)
self._fill(ref, 12)
confirmed = mx.full((1, 1, 1, 4), 100.0)
draft = mx.full((1, 1, 1, 4), 200.0)
both = mx.concatenate([confirmed, draft], axis=2)
cache_rollback.set_undo_armed(True)
try:
cache.update_and_fetch(both, both)
finally:
cache_rollback.set_undo_armed(False)
assert cache.is_trimmable()
assert cache.trim(1) == 1
ref.update_and_fetch(confirmed, confirmed)
nxt = mx.full((1, 1, 1, 4), 300.0)
ck, cv = cache.update_and_fetch(nxt, nxt)
rk, rv = ref.update_and_fetch(nxt, nxt)
mx.eval(ck, cv, rk, rv)
assert mx.array_equal(ck, rk).item()
assert mx.array_equal(cv, rv).item()
c_off = cache.offset
r_off = ref.offset
if hasattr(c_off, "tolist"):
assert c_off.tolist() == r_off.tolist()
else:
assert c_off == r_off
def test_rotating_kv_cache_undo(self):
from mlx_lm.models.cache import RotatingKVCache
self._run_equivalence(lambda: RotatingKVCache(max_size=8))
def test_batch_rotating_kv_cache_undo(self):
from mlx_lm.models.cache import BatchRotatingKVCache
self._run_equivalence(lambda: BatchRotatingKVCache(8, [0]))
def test_unarmed_update_keeps_stock_semantics(self):
import mlx.core as mx
from mlx_lm.models.cache import RotatingKVCache
from omlx.patches.mlx_lm_mtp import cache_rollback
cache_rollback.apply()
cache = RotatingKVCache(max_size=8)
self._fill(cache, 12)
both = mx.full((1, 1, 2, 4), 7.0)
cache.update_and_fetch(both, both)
assert not cache.is_trimmable()
assert cache.trim(1) == 0
def test_grow_mode_trim_unchanged(self):
import mlx.core as mx
from mlx_lm.models.cache import RotatingKVCache
from omlx.patches.mlx_lm_mtp import cache_rollback
cache_rollback.apply()
cache = RotatingKVCache(max_size=64)
self._fill(cache, 4)
assert cache.is_trimmable()
assert cache.trim(1) == 1
assert cache.offset == 3