@@ -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 ,
0 commit comments