Skip to content

Commit 8b9f8a2

Browse files
Add Linux UTs for MLX incremental restored-decode wrappers
Inject fake mlx/mlx_lm modules (monkeypatch.setitem, auto-reverted) to exercise the wrapper control flow on Linux without Apple Silicon: - restored_prefill_cache: inject-config targets only has_kv source layers with restored K/V (sharers/missing skipped), make_prompt_cache threaded + returned, evicted-position clamping, attention class restored + configs cleared on exit. - restored_incremental_generate: argmax first token, max_tokens<=1 early-exit, first-token EOS stop, stream-until-EOS, stream-until-max_tokens. restored_prefill_cache (371-423) and restored_incremental_generate (425-455) are now 100% line-covered. MLX-kernel paths (dispatch internals, capture_own_kv, restored_logits forwards) remain Mac-validated. 16/16 MLX tests pass. Co-authored-by: FluffyAIcode <FluffyAIcode@users.noreply.github.com>
1 parent 46db9e1 commit 8b9f8a2

1 file changed

Lines changed: 194 additions & 0 deletions

File tree

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
"""Linux-CI tests for the MLX incremental restored-decode wrappers
2+
(``restored_prefill_cache`` / ``restored_incremental_generate``).
3+
4+
These functions import ``mlx`` / ``mlx_lm`` lazily, so to exercise their
5+
control flow on Linux (no Apple Silicon) we inject minimal fake ``mlx.core``
6+
and ``mlx_lm`` modules via ``monkeypatch.setitem(sys.modules, ...)`` (auto
7+
reverted). The real MLX kernels/cache behaviour are validated on a Mac by
8+
``scripts/research/k3_integrated_niah_eval_mac.py --incremental``; here we lock
9+
in the wrapper logic: which layers get the inject config, cache plumbing, and
10+
the argmax/EOS/stop-condition decode loop.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import sys
16+
import types
17+
18+
import pytest
19+
20+
from inference_engine.backends.mlx import cross_model_dlm_verifier as cmv
21+
22+
23+
# --------------------------------------------------------------------------- #
24+
# Fake model structure
25+
# --------------------------------------------------------------------------- #
26+
class _FakeAttn:
27+
def __init__(self, layer_idx, has_kv=True):
28+
self.layer_idx = layer_idx
29+
self.has_kv = has_kv
30+
31+
def __call__(self, *a, **k): # present so _patched_attention_class can swap
32+
raise AssertionError("attn should not be invoked by the fake model")
33+
34+
35+
class _FakeLayer:
36+
def __init__(self, attn):
37+
self.self_attn = attn
38+
39+
40+
class _FakeTextModel:
41+
def __init__(self, n=6, shared=()):
42+
self.layers = [_FakeLayer(_FakeAttn(i, has_kv=i not in shared))
43+
for i in range(n)]
44+
self.previous_kvs = list(range(n))
45+
self.embed_tokens = object() # resolve_mlx_text_model sentinel
46+
47+
48+
class _Logits:
49+
"""Supports ``logits[0, -1]`` -> the last-row vocab list."""
50+
def __init__(self, row):
51+
self._row = row
52+
53+
def __getitem__(self, key):
54+
assert key == (0, -1)
55+
return list(self._row)
56+
57+
58+
class _FakeModel:
59+
"""mlx_lm-like wrapper: ``.model`` is the text model and it is callable."""
60+
def __init__(self, tm, last_row):
61+
self.model = tm
62+
self._row = last_row
63+
self.captured_inject = None
64+
self.last_cache = "UNSET"
65+
66+
def __call__(self, ids, cache=None):
67+
self.captured_inject = [
68+
l.self_attn.layer_idx for l in self.model.layers
69+
if getattr(l.self_attn, "_kakeya_inject", None)
70+
and l.self_attn._kakeya_inject.get("mode") == "inject"
71+
]
72+
self.last_cache = cache
73+
return _Logits(self._row)
74+
75+
76+
# --------------------------------------------------------------------------- #
77+
# Fake mlx / mlx_lm modules
78+
# --------------------------------------------------------------------------- #
79+
class _Scalar:
80+
def __init__(self, v):
81+
self._v = v
82+
83+
def item(self):
84+
return self._v
85+
86+
87+
def _install_fakes(monkeypatch, *, prompt_cache="CACHE", gen_stream=()):
88+
mx = types.ModuleType("mlx.core")
89+
mx.array = lambda x, **k: x
90+
mx.eval = lambda *a, **k: None
91+
mx.argmax = lambda row, **k: _Scalar(int(max(range(len(row)),
92+
key=lambda i: row[i])))
93+
mlx_pkg = types.ModuleType("mlx")
94+
mlx_pkg.core = mx
95+
96+
base = types.ModuleType("mlx_lm.models.base")
97+
base.scaled_dot_product_attention = lambda *a, **k: None
98+
cache_mod = types.ModuleType("mlx_lm.models.cache")
99+
cache_mod.make_prompt_cache = lambda model, **k: prompt_cache
100+
gen_mod = types.ModuleType("mlx_lm.generate")
101+
102+
def _generate_step(prompt, model, *, prompt_cache=None, max_tokens=256, **k):
103+
for i, tok in enumerate(gen_stream):
104+
if i >= max_tokens:
105+
break
106+
yield tok, 0.0
107+
gen_mod.generate_step = _generate_step
108+
109+
models_pkg = types.ModuleType("mlx_lm.models")
110+
mlx_lm_pkg = types.ModuleType("mlx_lm")
111+
for name, mod in [
112+
("mlx", mlx_pkg), ("mlx.core", mx),
113+
("mlx_lm", mlx_lm_pkg), ("mlx_lm.models", models_pkg),
114+
("mlx_lm.models.base", base), ("mlx_lm.models.cache", cache_mod),
115+
("mlx_lm.generate", gen_mod),
116+
]:
117+
monkeypatch.setitem(sys.modules, name, mod)
118+
119+
120+
# --------------------------------------------------------------------------- #
121+
# restored_prefill_cache
122+
# --------------------------------------------------------------------------- #
123+
def test_prefill_injects_only_source_layers_with_restored_kv(monkeypatch):
124+
_install_fakes(monkeypatch)
125+
tm = _FakeTextModel(n=6, shared=(5,)) # layer 5 is a KV-sharer
126+
model = _FakeModel(tm, last_row=[0.1, 0.9, 0.2])
127+
rk = {0: "k0", 2: "k2", 5: "k5"} # 5 is sharer -> skipped
128+
rv = {0: "v0", 2: "v2", 5: "v5"}
129+
cache, last = cmv.restored_prefill_cache(
130+
model, [10, 11, 12, 13],
131+
restored_k_per_layer=rk, restored_v_per_layer=rv,
132+
evicted_positions=[1, 2])
133+
# Only has_kv layers present in rk get injected (0, 2). Layer 5 is a sharer
134+
# (skipped); layers 1,3,4 have no restored K/V (skipped).
135+
assert model.captured_inject == [0, 2]
136+
# Cache from make_prompt_cache is threaded into the forward and returned.
137+
assert cache == "CACHE"
138+
assert model.last_cache == "CACHE"
139+
# Last-row logits returned (predicts first token).
140+
assert last == [0.1, 0.9, 0.2]
141+
142+
143+
def test_prefill_evicted_mask_clamped_and_attention_restored(monkeypatch):
144+
_install_fakes(monkeypatch)
145+
tm = _FakeTextModel(n=3)
146+
attn_cls = type(tm.layers[0].self_attn)
147+
orig_call = attn_cls.__call__
148+
model = _FakeModel(tm, last_row=[1.0, 0.0])
149+
# out-of-range evicted positions are ignored (clamped to prompt length)
150+
cmv.restored_prefill_cache(
151+
model, [7, 8], restored_k_per_layer={0: "k"}, restored_v_per_layer={0: "v"},
152+
evicted_positions=[0, 99, -1])
153+
# Attention __call__ restored after the context manager and inject config
154+
# cleared from every layer.
155+
assert attn_cls.__call__ is orig_call
156+
for l in tm.layers:
157+
assert not hasattr(l.self_attn, "_kakeya_inject")
158+
159+
160+
# --------------------------------------------------------------------------- #
161+
# restored_incremental_generate
162+
# --------------------------------------------------------------------------- #
163+
def test_generate_single_token_when_max_tokens_one(monkeypatch):
164+
_install_fakes(monkeypatch, gen_stream=[5, 6, 7])
165+
model = _FakeModel(_FakeTextModel(), last_row=None)
166+
out = cmv.restored_incremental_generate(
167+
model, "CACHE", [0.0, 0.0, 1.0], max_tokens=1)
168+
assert out == [2] # argmax of first_logits, no decode
169+
170+
171+
def test_generate_stops_when_first_is_eos(monkeypatch):
172+
_install_fakes(monkeypatch, gen_stream=[5, 6])
173+
model = _FakeModel(_FakeTextModel(), last_row=None)
174+
out = cmv.restored_incremental_generate(
175+
model, "CACHE", [0.0, 9.0], max_tokens=16, eos_ids=[1])
176+
assert out == [1] # first token is EOS -> stop
177+
178+
179+
def test_generate_streams_until_eos(monkeypatch):
180+
_install_fakes(monkeypatch, gen_stream=[5, 6, 99, 7])
181+
model = _FakeModel(_FakeTextModel(), last_row=None)
182+
out = cmv.restored_incremental_generate(
183+
model, "CACHE", [0.0, 0.0, 1.0], max_tokens=16, eos_ids=[99])
184+
# first = argmax([..1.0]) = 2, then stream 5,6 then EOS 99 (included, stops)
185+
assert out == [2, 5, 6, 99]
186+
187+
188+
def test_generate_streams_until_max_tokens(monkeypatch):
189+
_install_fakes(monkeypatch, gen_stream=[5, 6, 7, 8, 9])
190+
model = _FakeModel(_FakeTextModel(), last_row=None)
191+
out = cmv.restored_incremental_generate(
192+
model, "CACHE", [9.0, 0.0], max_tokens=3)
193+
# first = argmax([9,0]) = 0, then generate_step capped at max_tokens-1 = 2
194+
assert out == [0, 5, 6]

0 commit comments

Comments
 (0)