Skip to content

Commit d11020e

Browse files
feat(mlx): L>=2 padded batched decode workaround for the batch>1 quantized kernel bug
Every decode step feeds the new token duplicated (length-2 query) so the forward routes through mlx's matrix-matrix (qmm) quantized kernel instead of the single-token (qmv) decode kernel suspected of the batch>1 bug. Position 0 is the real next-token prediction (attends only to cache+self, == the L=1 result); the duplicate at position 1 is trimmed so the cache and global offset stay at the true position. Stays batched/parallel over sessions (B untouched), Python-only. Forces the trimmable Kakeya S5 cache. Adds mlx-batched-pad-decode bridge preset + manifest tests. Co-authored-by: FluffyAIcode <FluffyAIcode@users.noreply.github.com>
1 parent 8e8e841 commit d11020e

3 files changed

Lines changed: 94 additions & 5 deletions

File tree

inference_engine/bridge/manifest.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,31 @@ def _harness_preset(
181181
timeout_minutes=60,
182182
validate_reports=False,
183183
),
184+
Preset(
185+
name="mlx-batched-pad-decode",
186+
description="Candidate fix: MLX batched multi-tenant with the L>=2 "
187+
"padded decode workaround (duplicate the new token so "
188+
"every decode forward is length-2 and avoids mlx's L=1 "
189+
"B>1 single-token quantized kernel — the suspected "
190+
"core-kernel bug). Stays batched/parallel over "
191+
"sessions, Python-only; forces the trimmable Kakeya S5 "
192+
"cache. Expect per-session batched recall -> serialized "
193+
"(1.0).",
194+
command_templates=(
195+
(
196+
"python3", "scripts/research/mlx_batched_multitenant_bench.py",
197+
"--verifier-path", "${ENV:KAKEYA_MAC_VERIFIER_PATH}",
198+
"--sessions", "8",
199+
"--haystack-lines", "60",
200+
"--max-new-tokens", "24",
201+
"--pad-decode", "--sink", "4", "--window", "64",
202+
"--output",
203+
"results/research/k3_mac_bridge_mlx_batched_pad_decode.json",
204+
),
205+
),
206+
timeout_minutes=90,
207+
validate_reports=False,
208+
),
184209
Preset(
185210
name="mlx-batched-kakeya-cache",
186211
description="Fix test: MLX batched multi-tenant with Kakeya's "

scripts/research/mlx_batched_multitenant_bench.py

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,28 @@ def main() -> int:
4747
"attention with a manual batched matmul-softmax SDPA "
4848
"(works around the suspected batch>1 + GQA fast-kernel "
4949
"bug). The candidate fix.")
50+
ap.add_argument("--pad-decode", action="store_true",
51+
help="L>=2 padded batched decode workaround: every decode "
52+
"step feeds a length-2 query (the new token "
53+
"duplicated) so the forward never enters mlx's L=1 "
54+
"B>1 single-token (qmv) quantized-decode kernel "
55+
"(the suspected core-kernel bug). The logits at query "
56+
"position 0 give the next token; the duplicate at "
57+
"position 1 is trimmed so the cache stays at the true "
58+
"position. Stays batched/parallel over sessions "
59+
"(the B dimension is untouched), Python-only. Requires "
60+
"a trimmable cache, so it forces --kakeya-cache.")
5061
ap.add_argument("--output", default=None)
5162
args = ap.parse_args()
5263

64+
if args.pad_decode and not args.kakeya_cache:
65+
# The padded loop trims the duplicate KV each step; only the
66+
# concat SinkWindowKVCache is soundly trimmable (gemma's native
67+
# RotatingKVCache is not once the sliding ring wraps).
68+
args.kakeya_cache = True
69+
print("[mlx-mt] --pad-decode forces --kakeya-cache (trimmable cache "
70+
"needed to drop the padding token)", flush=True)
71+
5372
import mlx.core as mx
5473
import mlx_lm
5574
sys.path.insert(0, "sdks/python")
@@ -170,16 +189,49 @@ def decode_batched(cache, logits, max_tokens):
170189
dt = time.perf_counter() - t0
171190
return gen, dt
172191

192+
def decode_batched_padded(cache, logits, max_tokens):
193+
"""L>=2 padded decode: feed the new token duplicated so the
194+
forward routes through mlx's matrix-matrix (qmm) quantized
195+
kernel instead of the single-token (qmv) decode kernel suspected
196+
of the batch>1 bug. Position 0 is the real token (attends only to
197+
the cache + itself, so its logits == the L=1 decode result);
198+
position 1 is the duplicate, trimmed afterwards so the cache and
199+
global offset stay at the true position. B (sessions) untouched.
200+
"""
201+
B = logits.shape[0]
202+
nxt = mx.argmax(logits, axis=-1)
203+
gen = [[int(nxt[i].item())] for i in range(B)]
204+
mx.eval(nxt)
205+
t0 = time.perf_counter()
206+
for _ in range(max_tokens - 1):
207+
cur = nxt.reshape(B, 1)
208+
pair = mx.concatenate([cur, cur], axis=1) # [B, 2], L=2
209+
out = model(pair, cache=cache)
210+
mx.eval(out)
211+
# position 0 == the real next-token prediction (L=1-equivalent)
212+
nxt = mx.argmax(out[:, 0, :], axis=-1)
213+
for layer in cache:
214+
layer.trim(1) # drop the duplicate (position 1)
215+
for i in range(B):
216+
gen[i].append(int(nxt[i].item()))
217+
dt = time.perf_counter() - t0
218+
return gen, dt
219+
220+
decode = decode_batched_padded if args.pad_decode else decode_batched
221+
if args.pad_decode:
222+
print("[mlx-mt] decode path: L>=2 padded (qmm, avoids L=1 qmv kernel)",
223+
flush=True)
224+
173225
# warmup
174226
try:
175227
c, l = prefill_batched([prompts[0]] * min(2, N))
176-
decode_batched(c, l, 4)
228+
decode(c, l, 4)
177229
except Exception as e: # noqa: BLE001
178230
print(f"[mlx-mt] warmup note: {e}", flush=True)
179231

180232
# batched
181233
cache, logits = prefill_batched(prompts)
182-
g_b, dt_b = decode_batched(cache, logits, args.max_new_tokens)
234+
g_b, dt_b = decode(cache, logits, args.max_new_tokens)
183235
batched_tps = round((N * args.max_new_tokens) / dt_b, 3) if dt_b > 0 else 0.0
184236
batched_recall = sum(recall(g_b[i], answers[i]) for i in range(N)) / N
185237

@@ -188,13 +240,13 @@ def decode_batched(cache, logits, max_tokens):
188240
g_s = []
189241
for i in range(N):
190242
c, l = prefill_batched([prompts[i]])
191-
gg, _ = decode_batched(c, l, args.max_new_tokens)
243+
gg, _ = decode(c, l, args.max_new_tokens)
192244
g_s.append(gg[0])
193245
# serialized decode-only time: re-time decode alone (prefill excluded for fair tps)
194246
ser_decode_s = 0.0
195247
for i in range(N):
196248
c, l = prefill_batched([prompts[i]])
197-
_, dt = decode_batched(c, l, args.max_new_tokens)
249+
_, dt = decode(c, l, args.max_new_tokens)
198250
ser_decode_s += dt
199251
serial_tps = round((N * args.max_new_tokens) / ser_decode_s, 3) if ser_decode_s else 0.0
200252
serial_recall = sum(recall(g_s[i], answers[i]) for i in range(N)) / N
@@ -215,7 +267,10 @@ def decode_batched(cache, logits, max_tokens):
215267
"kind": "mlx_batched_multitenant",
216268
"config": {"sessions": N, "modal_prompt_len": modal,
217269
"max_new_tokens": args.max_new_tokens,
218-
"verifier_path": args.verifier_path},
270+
"verifier_path": args.verifier_path,
271+
"kakeya_cache": bool(args.kakeya_cache),
272+
"manual_sdpa": bool(args.manual_sdpa),
273+
"pad_decode": bool(args.pad_decode)},
219274
"serialized": {"aggregate_tps": serial_tps, "recall": round(serial_recall, 3)},
220275
"batched": {"aggregate_tps": batched_tps, "recall": round(batched_recall, 3)},
221276
"batched_speedup_vs_serialized": speedup,

tests/inference_engine/bridge/test_manifest.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ def test_allowlist_contains_exactly_the_documented_presets():
7878
"mlx-batched-layer-diff-concat",
7979
"mlx-batched-manual-sdpa",
8080
"mlx-batched-multitenant",
81+
"mlx-batched-pad-decode",
8182
"mlx-env-probe",
8283
"mlx-multitenant-pressure",
8384
"pytest-path",
@@ -107,6 +108,14 @@ def test_allmlx_preset_carries_both_mode_flags():
107108
assert "--ignore-turn-stop" in argv
108109

109110

111+
def test_pad_decode_preset_carries_flag_and_forces_trimmable_cache():
112+
request = parse_manifest(_manifest(preset="mlx-batched-pad-decode"))
113+
(argv,) = build_commands(request, HARNESS_ENV)
114+
assert argv[1].endswith("mlx_batched_multitenant_bench.py")
115+
assert "--pad-decode" in argv
116+
assert HARNESS_ENV["KAKEYA_MAC_VERIFIER_PATH"] in argv
117+
118+
110119
def test_drafter_parity_preset_resolves():
111120
request = parse_manifest(_manifest(
112121
preset="k3-drafter-parity", params={"block_size": "8"}))

0 commit comments

Comments
 (0)