Files
jundot--omlx/tests/test_glm_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

402 lines
15 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Tests for the GLM-5.2 (glm_moe_dsa) native MTP patch."""
import sys
import mlx.core as mx
import mlx.utils as mu
import pytest
from omlx.patches.glm_moe_dsa import apply_glm_moe_dsa_patch
from omlx.patches.mlx_lm_mtp import apply_mlx_lm_mtp_patch, set_mtp_active
@pytest.fixture(scope="module")
def glm():
apply_glm_moe_dsa_patch()
apply_mlx_lm_mtp_patch()
return sys.modules["mlx_lm.models.glm_moe_dsa"]
@pytest.fixture()
def mtp_active():
set_mtp_active(True)
yield
set_mtp_active(False)
TINY_CFG = dict(
model_type="glm_moe_dsa",
vocab_size=128,
hidden_size=64,
index_head_dim=32,
index_n_heads=4,
index_topk=16,
intermediate_size=96,
moe_intermediate_size=32,
num_hidden_layers=2,
num_attention_heads=4,
num_key_value_heads=4,
n_shared_experts=1,
n_routed_experts=4,
routed_scaling_factor=1.0,
kv_lora_rank=32,
q_lora_rank=48,
qk_rope_head_dim=16,
v_head_dim=32,
qk_nope_head_dim=24,
topk_method="noaux_tc",
scoring_func="sigmoid",
norm_topk_prob=True,
n_group=1,
topk_group=1,
num_experts_per_tok=2,
moe_layer_freq=1,
first_k_dense_replace=1,
max_position_embeddings=512,
rms_norm_eps=1e-5,
rope_parameters={"rope_theta": 10000.0, "rope_type": "default"},
attention_bias=False,
index_topk_freq=4,
index_skip_topk_offset=3,
indexer_types=["full", "shared"],
num_nextn_predict_layers=1,
)
def _raw_hf_weights(glm, model):
"""Rebuild a raw-HF-layout weights dict from a built model's params.
Inverts the sanitize transforms for the MTP layer (switch stacking,
gate_up fusion, embed_q/unembed_out) so sanitize can be exercised on
checkpoint-shaped input.
"""
cfg = TINY_CFG
flat = dict(mu.tree_flatten(model.parameters()))
weights = {}
for k, v in flat.items():
if k.startswith("mtp.0."):
rest = k[len("mtp.0."):]
if rest.startswith("block."):
rk = "model.layers.2." + rest[len("block."):]
elif rest == "norm.weight":
rk = "model.layers.2.shared_head.norm.weight"
else:
rk = "model.layers.2." + rest
weights[rk] = v
else:
weights[k] = v
raw = {}
for k, v in weights.items():
if ".mlp.switch_mlp.gate_up_proj.weight" in k:
base = k.split(".mlp.switch_mlp.")[0]
gate, up = mx.split(v, 2, axis=1)
for e in range(v.shape[0]):
raw[f"{base}.mlp.experts.{e}.gate_proj.weight"] = gate[e]
raw[f"{base}.mlp.experts.{e}.up_proj.weight"] = up[e]
elif ".mlp.switch_mlp.down_proj.weight" in k:
base = k.split(".mlp.switch_mlp.")[0]
for e in range(v.shape[0]):
raw[f"{base}.mlp.experts.{e}.down_proj.weight"] = v[e]
elif ".self_attn.embed_q.weight" in k:
continue # regenerated from the fabricated kv_b_proj below
elif ".self_attn.unembed_out.weight" in k:
base = k.split(".self_attn.")[0]
nh = cfg["num_attention_heads"]
hd = cfg["qk_nope_head_dim"] + cfg["v_head_dim"]
raw[f"{base}.self_attn.kv_b_proj.weight"] = mx.random.normal(
(nh * hd, cfg["kv_lora_rank"])
)
else:
raw[k] = v
return raw
class TestModelArgs:
def test_nextn_count_and_indexer_extension(self, glm):
args = glm.ModelArgs.from_dict(TINY_CFG)
assert args.num_nextn_predict_layers == 1
# freq=4/offset=3: layer 2 -> max(0,0)%4==0 -> "full"
assert args.indexer_types == ["full", "shared", "full"]
def test_no_nextn_is_untouched(self, glm):
cfg = dict(TINY_CFG, num_nextn_predict_layers=0)
args = glm.ModelArgs.from_dict(cfg)
assert args.num_nextn_predict_layers == 0
assert args.indexer_types == ["full", "shared"]
class TestModelInit:
def test_mtp_attached_when_active(self, glm, mtp_active):
args = glm.ModelArgs.from_dict(TINY_CFG)
model = glm.Model(args)
assert hasattr(model, "mtp") and len(model.mtp) == 1
assert model._omlx_mtp_decode_enabled
assert model._omlx_mtp_chain
assert model._omlx_mtp_head_clone is False
def test_mtp_skipped_when_inactive(self, glm):
set_mtp_active(False)
args = glm.ModelArgs.from_dict(TINY_CFG)
model = glm.Model(args)
assert not hasattr(model, "mtp")
assert model._omlx_mtp_decode_enabled is False
class TestSanitize:
def test_raw_hf_remap_and_strict_load(self, glm, mtp_active):
mx.random.seed(0)
args = glm.ModelArgs.from_dict(TINY_CFG)
model = glm.Model(args)
raw = _raw_hf_weights(glm, model)
out = model.sanitize(raw)
assert not any(".layers.2." in k for k in out)
for expected in (
"mtp.0.eh_proj.weight",
"mtp.0.enorm.weight",
"mtp.0.hnorm.weight",
"mtp.0.norm.weight",
"mtp.0.block.mlp.switch_mlp.gate_up_proj.weight",
"mtp.0.block.self_attn.embed_q.weight",
"mtp.0.block.self_attn.indexer.wk.weight",
):
assert expected in out, expected
model.load_weights(list(out.items()), strict=True)
def test_layer_count_restored_after_sanitize(self, glm, mtp_active):
args = glm.ModelArgs.from_dict(TINY_CFG)
model = glm.Model(args)
raw = _raw_hf_weights(glm, model)
model.sanitize(raw)
assert model.args.num_hidden_layers == TINY_CFG["num_hidden_layers"]
def test_presanitized_passthrough(self, glm, mtp_active):
"""oQ-style checkpoints (already mtp.*) survive a second sanitize."""
mx.random.seed(0)
args = glm.ModelArgs.from_dict(TINY_CFG)
model = glm.Model(args)
once = model.sanitize(_raw_hf_weights(glm, model))
twice = model.sanitize(dict(once))
assert sorted(twice) == sorted(once)
model.load_weights(list(twice.items()), strict=True)
def test_mtp_off_drops_all_mtp_keys(self, glm, mtp_active):
mx.random.seed(0)
args = glm.ModelArgs.from_dict(TINY_CFG)
model = glm.Model(args)
sanitized = model.sanitize(_raw_hf_weights(glm, model))
set_mtp_active(False)
model_off = glm.Model(args)
out = model_off.sanitize(dict(sanitized))
assert not any(k.startswith("mtp.") for k in out)
model_off.load_weights(list(out.items()), strict=True)
def test_missing_head_weights_degrades_gracefully(self, glm, mtp_active):
mx.random.seed(0)
args = glm.ModelArgs.from_dict(TINY_CFG)
model = glm.Model(args)
sanitized = model.sanitize(_raw_hf_weights(glm, model))
stripped = {k: v for k, v in sanitized.items() if not k.startswith("mtp.")}
model2 = glm.Model(args)
out = model2.sanitize(stripped)
assert not hasattr(model2, "mtp")
assert model2._omlx_mtp_decode_enabled is False
model2.load_weights(list(out.items()), strict=True)
class TestIndexerFusion:
def test_mtp_indexer_fused_alongside_backbone(self, glm, mtp_active):
"""MTP indexer fusion must happen before the stock sanitize: its
backbone fusion pass drops every unfused ``.indexer.wk`` /
``.weights_proj`` key by substring, MTP keys included."""
cfg = dict(TINY_CFG)
q8 = {"bits": 8, "group_size": 64, "mode": "affine"}
cfg["quantization"] = {
"group_size": 64,
"bits": 4,
"mode": "affine",
"model.layers.0.self_attn.indexer.wk": dict(q8),
"model.layers.0.self_attn.indexer.weights_proj": dict(q8),
}
args = glm.ModelArgs.from_dict(cfg)
model = glm.Model(args)
assert model.mtp[0].block.self_attn.indexer.wk_weights_proj is not None
h = TINY_CFG["hidden_size"]
hd = TINY_CFG["index_head_dim"]
nh = TINY_CFG["index_n_heads"]
weights = {}
for prefix in (
"model.layers.0.self_attn.indexer",
"mtp.0.block.self_attn.indexer",
):
for suffix in ("weight", "scales", "biases"):
weights[f"{prefix}.wk.{suffix}"] = mx.zeros((hd, 4))
weights[f"{prefix}.weights_proj.{suffix}"] = mx.zeros((nh, 4))
out = model.sanitize(weights)
for prefix in (
"model.layers.0.self_attn.indexer",
"mtp.0.block.self_attn.indexer",
):
assert f"{prefix}.wk_weights_proj.weight" in out, prefix
assert f"{prefix}.wk.weight" not in out
assert f"{prefix}.weights_proj.weight" not in out
assert out["mtp.0.block.self_attn.indexer.wk_weights_proj.weight"].shape == (
hd + nh,
4,
)
class TestForward:
@pytest.fixture()
def loaded(self, glm, mtp_active):
mx.random.seed(0)
args = glm.ModelArgs.from_dict(TINY_CFG)
model = glm.Model(args)
out = model.sanitize(_raw_hf_weights(glm, model))
model.load_weights(list(out.items()), strict=True)
mx.eval(model.parameters())
return model
def test_return_hidden_and_mtp_cycle(self, loaded):
model = loaded
cache = model.make_cache()
toks = mx.array([[1, 2, 3, 4]])
logits, hidden = model(toks, cache=cache, return_hidden=True)
mx.eval(logits, hidden)
assert logits.shape == (1, 4, TINY_CFG["vocab_size"])
assert hidden.shape == (1, 4, TINY_CFG["hidden_size"])
# hidden is pre-norm: normed hidden feeds the head (post-norm contract)
post = model.model.norm(hidden)
mtp_cache = model.make_mtp_cache()
assert isinstance(mtp_cache, list) and len(mtp_cache) == 2
lg, hh = model.mtp_forward(
post, toks, mtp_cache, return_hidden=True, logits_keep=1
)
mx.eval(lg, hh)
assert lg.shape == (1, 1, TINY_CFG["vocab_size"])
assert hh.shape == (1, 4, TINY_CFG["hidden_size"])
assert mtp_cache[0].offset == 4 and mtp_cache[1].offset == 4
# chained draft step + rollback trim
lg2, _ = model.mtp_forward(
hh[:, -1:], mx.array([[7]]), mtp_cache, return_hidden=True
)
mx.eval(lg2)
assert mtp_cache[0].offset == 5
from omlx.patches.mlx_lm_mtp.batch_generator import _mtp_head_trim_to
_mtp_head_trim_to(mtp_cache, 4)
assert mtp_cache[0].offset == 4 and mtp_cache[1].offset == 4
def test_partial_rollback_trims_verify_window(self, loaded):
model = loaded
cache = model.make_cache()
logits, _ = model(mx.array([[1, 2, 3]]), cache=cache, return_hidden=True)
mx.eval(logits, *(c[0].keys for c in cache))
base = cache[0][0].offset
# verify window: num_drafts + 1 rows, accept 1 of 3 drafts
logits, _ = model(
mx.array([[4, 5, 6, 7]]), cache=cache, return_hidden=True
)
mx.eval(logits, *(c[0].keys for c in cache))
assert cache[0][0].offset == base + 4
assert model.mtp_partial_rollback(cache, 1, 3)
for c in cache:
for sub in c.caches: # latent KV (+ indexer KV on full layers)
assert sub.offset == base + 2 # next_main + 1 accepted draft
def test_n_confirmed_accepted(self, loaded):
cache = loaded.make_cache()
logits, _ = loaded(
mx.array([[1, 2]]), cache=cache, return_hidden=True, n_confirmed=1
)
mx.eval(logits)
assert logits.shape == (1, 2, TINY_CFG["vocab_size"])
class TestSmallLRouting:
def test_absorbed_matches_materialized(self, glm, mtp_active):
"""The widened L<=8 absorbed path equals the legacy materialize path."""
import omlx.patches.glm_moe_dsa.glm_moe_dsa_model as gm
from mlx_lm.models.base import create_attention_mask
from mlx_lm.models.cache import KVCache
mx.random.seed(3)
args = glm.ModelArgs.from_dict(TINY_CFG)
attn = glm.GlmMoeDsaAttention(args, 0)
mx.eval(attn.parameters())
def run(L, max_l):
mx.random.seed(11)
cache = [KVCache(), KVCache()]
x_pre = mx.random.normal((1, 12, TINY_CFG["hidden_size"]))
mask = create_attention_mask(x_pre, cache[0], return_array=True)
out, _ = attn(x_pre, mask, cache, None)
mx.eval(out)
x = mx.random.normal((1, L, TINY_CFG["hidden_size"]))
mask = create_attention_mask(x, cache[0], return_array=True)
saved = gm._ABSORBED_DECODE_MAX_L
gm._ABSORBED_DECODE_MAX_L = max_l
try:
out, _ = attn(x, mask, cache, None)
mx.eval(out)
finally:
gm._ABSORBED_DECODE_MAX_L = saved
return out
for L in (2, 3, 4, 8):
legacy = run(L, 1)
absorbed = run(L, 8)
diff = float(mx.abs(legacy - absorbed).max())
assert diff < 2e-5, f"L={L}: {diff}"
def test_topk_gather_matches_masked_reference(self, glm, mtp_active):
"""With the DSA indexer active (K > index_topk), the decode-shape
per-row gather path must equal the legacy masked full-K path."""
import omlx.patches.glm_moe_dsa.glm_moe_dsa_model as gm
from mlx_lm.models.base import create_attention_mask
from mlx_lm.models.cache import KVCache
mx.random.seed(5)
args = glm.ModelArgs.from_dict(TINY_CFG)
attn = glm.GlmMoeDsaAttention(args, 0)
mx.eval(attn.parameters())
def run(L, max_l):
mx.random.seed(17)
cache = [KVCache(), KVCache()]
# Prefill past index_topk (16) so the indexer emits topk state.
x_pre = mx.random.normal((1, 24, TINY_CFG["hidden_size"]))
mask = create_attention_mask(x_pre, cache[0], return_array=True)
out, _ = attn(x_pre, mask, cache, None)
mx.eval(out)
x = mx.random.normal((1, L, TINY_CFG["hidden_size"]))
mask = create_attention_mask(x, cache[0], return_array=True)
saved = gm._ABSORBED_DECODE_MAX_L
gm._ABSORBED_DECODE_MAX_L = max_l
try:
out, state = attn(x, mask, cache, None)
mx.eval(out)
finally:
gm._ABSORBED_DECODE_MAX_L = saved
return out, state
for L in (2, 3, 4):
legacy, legacy_state = run(L, 1) # masked materialize fallback
gathered, state = run(L, 8) # per-row topk gather
idx, prefix = gm._parse_topk_state(state)
assert idx is not None and idx.shape[2] == L and prefix == 0
diff = float(mx.abs(legacy - gathered).max())
assert diff < 2e-5, f"L={L}: {diff}"