|
| 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