Skip to content

Commit 235c8ac

Browse files
diag(mlx): per-layer batched-vs-serialized hidden diff to localize gemma-4 batch>1 decode divergence
Co-authored-by: FluffyAIcode <FluffyAIcode@users.noreply.github.com>
1 parent 92a8c5e commit 235c8ac

3 files changed

Lines changed: 149 additions & 0 deletions

File tree

inference_engine/bridge/manifest.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,22 @@ def _harness_preset(
131131
),
132132
timeout_minutes=60,
133133
),
134+
Preset(
135+
name="mlx-batched-layer-diff",
136+
description="Localize the mlx_lm gemma-4 batch>1 decode bug: "
137+
"per-layer hidden-state diff (batched row-i vs "
138+
"serialized-i) at decode step 1; prints the first "
139+
"divergent layer + its type/shared-KV status.",
140+
command_templates=(
141+
(
142+
"python3", "scripts/research/mlx_batched_layer_diff_diag.py",
143+
"--verifier-path", "${ENV:KAKEYA_MAC_VERIFIER_PATH}",
144+
"--rows", "2", "--haystack-lines", "15",
145+
),
146+
),
147+
timeout_minutes=60,
148+
validate_reports=False,
149+
),
134150
Preset(
135151
name="mlx-batched-kakeya-cache",
136152
description="Fix test: MLX batched multi-tenant with Kakeya's "
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
"""Localize the mlx_lm gemma-4 batch>1 decode bug: per-layer hidden-state diff,
2+
batched-row-i vs serialized-i, at decode step 1.
3+
4+
Prefill is known correct (first decoded token matches per row); the divergence
5+
is in the batched decode forward. This dumps, per decoder layer, the max-abs
6+
difference between the batched forward's row-i output and the serialized
7+
single-row forward's output for the SAME session + SAME fed token. The first
8+
layer whose diff jumps locates the bug (layer 0 → RoPE/embed/per-layer-input;
9+
a sliding layer → sliding mask/cache; a full-attn layer → full path; the first
10+
KV-shared layer → shared-KV plumbing).
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import argparse
16+
import sys
17+
from collections import Counter
18+
from typing import List
19+
20+
21+
def main() -> int:
22+
ap = argparse.ArgumentParser(description=__doc__)
23+
ap.add_argument("--verifier-path", required=True)
24+
ap.add_argument("--rows", type=int, default=2)
25+
ap.add_argument("--haystack-lines", type=int, default=15)
26+
args = ap.parse_args()
27+
28+
import mlx.core as mx
29+
import mlx_lm
30+
sys.path.insert(0, "sdks/python")
31+
from inference_engine.v04 import make_niah_dataset
32+
from inference_engine.backends.mlx.cross_model_dlm_verifier import (
33+
resolve_mlx_text_model,
34+
)
35+
36+
model, tok = mlx_lm.load(args.verifier_path)
37+
text_model = resolve_mlx_text_model(model)
38+
layers = text_model.layers
39+
n_layers = len(layers)
40+
layer_types = [getattr(l, "layer_type", "?") for l in layers]
41+
first_shared = n_layers - getattr(model.args, "num_kv_shared_layers", 0)
42+
print(f"[diff] layers={n_layers} first_kv_shared_idx={first_shared}", flush=True)
43+
44+
R = args.rows
45+
pool = make_niah_dataset(n_samples=R * 3, haystack_min_lines=args.haystack_lines,
46+
haystack_max_lines=args.haystack_lines, seed=0)
47+
48+
def encode(text):
49+
text = text.replace("and does not contain the answer.",
50+
"and is unrelated filler.")
51+
text = text + "\n\nReturn only the secret code in PREFIX-NNNN format."
52+
ids = list(tok.apply_chat_template([{"role": "user", "content": text}],
53+
add_generation_prompt=True))
54+
try:
55+
m = tok.encode("<|channel>content\n<channel|>", add_special_tokens=False)
56+
except TypeError:
57+
m = tok.encode("<|channel>content\n<channel|>")
58+
ids.extend(list(m if not hasattr(m, "tolist") else m.tolist()))
59+
return ids
60+
61+
enc = [encode(s.prompt_text) for s in pool]
62+
modal = Counter(len(e) for e in enc).most_common(1)[0][0]
63+
prompts = [e for e in enc if len(e) == modal][:R]
64+
while len(prompts) < R:
65+
prompts += prompts[: R - len(prompts)]
66+
print(f"[diff] {R} rows, prompt len={modal}", flush=True)
67+
68+
# capture per-layer output hidden by monkey-patching DecoderLayer.__call__
69+
DecoderLayer = type(layers[0])
70+
orig_call = DecoderLayer.__call__
71+
captured: List = []
72+
73+
def patched(self, *a, **k):
74+
out = orig_call(self, *a, **k)
75+
captured.append(out[0]) # h: [B, L, D]
76+
return out
77+
78+
def prefill(ids_2d):
79+
cache = model.make_cache()
80+
out = model(mx.array(ids_2d), cache=cache)
81+
mx.eval(out)
82+
return cache, out[:, -1, :]
83+
84+
def decode_capture(token_ids_2d, cache):
85+
captured.clear()
86+
DecoderLayer.__call__ = patched
87+
try:
88+
out = model(mx.array(token_ids_2d), cache=cache)
89+
mx.eval(out)
90+
finally:
91+
DecoderLayer.__call__ = orig_call
92+
return list(captured), out
93+
94+
# serialized: prefill + first token + capture decode step
95+
ser_caches, ser_tok0, ser_layers = [], [], []
96+
for i in range(R):
97+
c, lg = prefill([prompts[i]])
98+
t0 = int(mx.argmax(lg, axis=-1).item())
99+
ser_tok0.append(t0)
100+
caps, _ = decode_capture([[t0]], c)
101+
ser_layers.append(caps) # n_layers x [1,1,D]
102+
103+
# batched: prefill + first tokens + capture decode step (same tokens)
104+
cb, lgb = prefill(prompts)
105+
bat_tok0 = [int(mx.argmax(lgb[i], axis=-1).item()) for i in range(R)]
106+
caps_b, _ = decode_capture([[t] for t in bat_tok0], cb) # n_layers x [R,1,D]
107+
108+
print(f"[diff] tok0 serial={ser_tok0} batched={bat_tok0} "
109+
f"match={ser_tok0 == bat_tok0}", flush=True)
110+
print("[diff] layer | type | shared? | max|Δ| row0 | row1 ...", flush=True)
111+
first_div = None
112+
for li in range(n_layers):
113+
hb = caps_b[li] # [R,1,D]
114+
diffs = []
115+
for i in range(R):
116+
d = float(mx.max(mx.abs(hb[i:i + 1] - ser_layers[i][li])).item())
117+
diffs.append(round(d, 4))
118+
shared = "shared" if li >= first_shared else ""
119+
mark = ""
120+
if first_div is None and max(diffs) > 1e-2:
121+
first_div = li
122+
mark = " <-- FIRST DIVERGENCE"
123+
print(f"[diff] {li:2d} | {layer_types[li]:18s} | {shared:6s} | {diffs}{mark}",
124+
flush=True)
125+
print(f"[diff] FIRST DIVERGENT LAYER = {first_div} "
126+
f"(type={layer_types[first_div] if first_div is not None else None}, "
127+
f"shared={first_div is not None and first_div >= first_shared})", flush=True)
128+
return 0
129+
130+
131+
if __name__ == "__main__":
132+
raise SystemExit(main())

tests/inference_engine/bridge/test_manifest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ def test_allowlist_contains_exactly_the_documented_presets():
7474
"mlx-backend-tests",
7575
"mlx-batched-diag-short",
7676
"mlx-batched-kakeya-cache",
77+
"mlx-batched-layer-diff",
7778
"mlx-batched-multitenant",
7879
"mlx-env-probe",
7980
"mlx-multitenant-pressure",

0 commit comments

Comments
 (0)