2727(``test_shifted_speculative_decode_is_lossless`` drives the full loop through only
2828these three methods).
2929
30- Export runs with the model on the host (CPU); AOTInductor streams weights to the
31- GPU per kernel during compilation, so peak GPU memory stays low even for the INT4
32- 31B target. The target is loaded from a prequantized (INT4) directory and the
33- draft from a vLLM-speculator checkpoint; only the CUDA (AOTI) backend is
34- supported.
30+ Export runs with the model on the host (CPU). For ``--backend cuda`` AOTInductor
31+ streams weights to the GPU per kernel during compilation, so peak GPU memory
32+ stays low even for the INT4 31B target. The target is loaded from a prequantized
33+ (INT4) directory and the draft from a vLLM-speculator checkpoint.
34+
35+ Backends:
36+ - ``cuda`` (AOTI): three methods (prefill, target_verify, draft_decode) sharing
37+ KV caches by FQN; bf16 draft.
38+ - ``mlx`` (Apple silicon): MLX has no cross-method KV-cache sharing, so prefill
39+ and verify are merged into one dynamic-seq ``target_forward`` (sharing the
40+ target cache within a single handle) plus ``draft_decode``; the draft is bf16
41+ by default (``--quantize-draft`` for int4). Both methods return logits (target
42+ soft-capped + draft) so the host applies temperature / sampling — greedy is
43+ host-side argmax.
3544
3645Scope (this is a fixed-shape ExecuTorch artifact, not a generic EAGLE runtime):
3746chain length, the chain_len+1 verify window, the prefill/draft dynamic ranges,
@@ -95,6 +104,35 @@ def forward(self, tokens, feature, input_pos):
95104 return self .spec .draft_decode (tokens , feature , input_pos )
96105
97106
107+ # Logit-returning variants for the MLX sampling path: the host applies
108+ # temperature + modified rejection sampling, so the methods return distributions
109+ # (soft-capped target logits / draft logits) instead of the greedy argmax. Greedy
110+ # (--temperature 0) just argmaxes these host-side.
111+
112+
113+ class _TargetForwardLogits (nn .Module ):
114+ def __init__ (self , spec : Eagle3Speculator ):
115+ super ().__init__ ()
116+ self .spec = spec
117+
118+ def forward (self , tokens , input_pos ):
119+ logits , taps = self .spec .target .forward_logits_taps (
120+ tokens , input_pos , last_logits_only = False
121+ )
122+ return logits , self .spec .draft .fuse (taps )
123+
124+
125+ class _DraftDecodeLogits (nn .Module ):
126+ def __init__ (self , spec : Eagle3Speculator ):
127+ super ().__init__ ()
128+ self .spec = spec
129+
130+ def forward (self , tokens , feature , input_pos ):
131+ emb = self .spec .draft .embed (tokens )
132+ draft_logits , g = self .spec .draft .forward_cached (emb , feature , input_pos )
133+ return draft_logits , g
134+
135+
98136def _export_cuda (
99137 spec : Eagle3Speculator ,
100138 output_dir : str ,
@@ -253,39 +291,140 @@ def _partitioner(name: str):
253291 print ("Done." )
254292
255293
256- def main () -> None :
257- p = argparse .ArgumentParser (description = "Export an EAGLE-3 speculator to .pte." )
258- p .add_argument (
259- "--target-model" ,
260- default = "gemma4_31b" ,
261- choices = list (TARGETS ),
262- help = "Registered target model (see eagle3/target.py)." ,
294+ def _export_mlx (
295+ spec : Eagle3Speculator ,
296+ output_dir : str ,
297+ max_prefill : int ,
298+ chain_len : int ,
299+ share_base_embedding : bool = False ,
300+ ) -> None :
301+ import executorch .backends .mlx .custom_kernel_ops .gguf .patterns # noqa: F401
302+ import executorch .extension .llm .export .gguf # noqa: F401
303+ import executorch .extension .llm .export .int4 # noqa: F401
304+ from executorch .backends .mlx import MLXPartitioner
305+ from executorch .backends .mlx .passes import get_default_passes
306+ from executorch .examples .models .gemma4_31b .mlx_source_transformations import (
307+ install_mlx_tap_forward ,
308+ mlx_source_transformations ,
263309 )
264- p .add_argument (
265- "--target" , required = True , help = "Prequantized (INT4) target directory."
310+ from executorch .examples .models .gemma4_31b .model import materialize_runtime_buffers
311+ from executorch .exir import (
312+ EdgeCompileConfig ,
313+ ExecutorchBackendConfig ,
314+ to_edge_transform_and_lower ,
266315 )
267- p .add_argument ("--draft" , required = True , help = "EAGLE-3 draft head directory." )
268- p .add_argument ("--output-dir" , default = "./eagle3_exports" )
269- p .add_argument ("--max-seq-len" , type = int , default = 4096 )
270- p .add_argument (
271- "--max-prefill" ,
272- type = int ,
273- default = 512 ,
274- help = "Max prefill length: AOTI compiles prefill kernels for up to this T "
275- "and the whole prompt must fit in one prefill (the runner does not chunk). "
276- "Smaller compiles faster." ,
316+ from executorch .exir .passes import MemoryPlanningPass
317+ from torch .export import Dim , export
318+
319+ target_config = spec .target .config
320+ hidden = spec .draft .config .hidden_size
321+ draft_vocab_size = spec .draft .config .draft_vocab_size
322+
323+ # MLX rewrites the target to mask-free layers + MLX KV caches; install a
324+ # matching mask-free tap forward so the speculator's target methods trace.
325+ mlx_source_transformations (spec .target , dtype = torch .bfloat16 )
326+ install_mlx_tap_forward (spec .target )
327+ materialize_runtime_buffers (spec .target , dtype = torch .bfloat16 )
328+
329+ if share_base_embedding :
330+ # Point the draft at the target's packed embedding so both methods emit
331+ # identical bytes; the NamedDataStore then content-dedups them to one
332+ # copy. Safe because the draft embed is a frozen copy of the target's and
333+ # the draft's input_layernorm (RMSNorm) is invariant to the embed scale.
334+ spec .draft .embed_tokens = spec .target .embed_tokens
335+
336+ # MLX has no cross-method KV-cache sharing, so prefill and verify are one
337+ # dynamic-seq method that shares the target cache within a single handle. The
338+ # method returns per-position logits; the host samples (or argmaxes).
339+ print (f"Exporting target_forward (T in [1, { max_prefill } ])..." )
340+ target_dim = Dim ("target_len" , min = 1 , max = max_prefill )
341+ with torch .no_grad ():
342+ target_ep = export (
343+ _TargetForwardLogits (spec ),
344+ (
345+ torch .zeros ((1 , max_prefill ), dtype = torch .long ),
346+ torch .arange (max_prefill , dtype = torch .long ),
347+ ),
348+ dynamic_shapes = ({1 : target_dim }, {0 : target_dim }),
349+ strict = True ,
350+ )
351+
352+ draft_max = max (max_prefill , chain_len + 1 )
353+ print (f"Exporting draft_decode (T in [1, { draft_max } ])..." )
354+ draft_dim = Dim ("draft_len" , min = 1 , max = draft_max )
355+ with torch .no_grad ():
356+ draft_ep = export (
357+ _DraftDecodeLogits (spec ),
358+ (
359+ torch .zeros ((1 , draft_max ), dtype = torch .long ),
360+ torch .zeros ((1 , draft_max , hidden ), dtype = torch .bfloat16 ),
361+ torch .arange (draft_max , dtype = torch .long ),
362+ ),
363+ dynamic_shapes = ({1 : draft_dim }, {1 : draft_dim }, {0 : draft_dim }),
364+ strict = True ,
365+ )
366+
367+ del spec
368+ gc .collect ()
369+
370+ print ("Lowering to ExecuTorch with MLX backend..." )
371+ et_prog = to_edge_transform_and_lower (
372+ {"target_forward" : target_ep , "draft_decode" : draft_ep },
373+ transform_passes = get_default_passes (),
374+ partitioner = {
375+ "target_forward" : [MLXPartitioner ()],
376+ "draft_decode" : [MLXPartitioner ()],
377+ },
378+ compile_config = EdgeCompileConfig (
379+ _check_ir_validity = False ,
380+ _skip_dim_order = True ,
381+ ),
382+ constant_methods = {
383+ "get_max_seq_len" : target_config .max_seq_len ,
384+ "get_vocab_size" : target_config .vocab_size ,
385+ "get_n_layers" : target_config .num_hidden_layers ,
386+ "get_max_prefill_chunk" : max_prefill ,
387+ "get_min_prefill_chunk" : 1 ,
388+ "get_chain_len" : chain_len ,
389+ "get_draft_vocab_size" : draft_vocab_size ,
390+ "use_kv_cache" : True ,
391+ "enable_dynamic_shape" : True ,
392+ },
277393 )
278- p .add_argument (
279- "--chain" , type = int , default = 4 , help = "Draft chain length K (verify K+1)."
394+ del target_ep , draft_ep
395+ gc .collect ()
396+
397+ et_program = et_prog .to_executorch (
398+ config = ExecutorchBackendConfig (
399+ extract_delegate_segments = True ,
400+ memory_planning_pass = MemoryPlanningPass (alloc_graph_input = False ),
401+ ),
280402 )
281- args = p .parse_args ()
403+ del et_prog
404+ gc .collect ()
282405
283- spec_t = TARGETS [args .target_model ]
284- if not torch .cuda .is_available ():
285- p .error ("CUDA is required to compile the EAGLE-3 export." )
406+ os .makedirs (output_dir , exist_ok = True )
407+ pte_path = os .path .join (output_dir , "model.pte" )
408+ print (f"Saving to { pte_path } ..." )
409+ with open (pte_path , "wb" ) as f :
410+ et_program .write_to_file (f )
411+ print (f" { os .path .getsize (pte_path ) / 1024 ** 2 :.1f} MB" )
412+ if et_program ._tensor_data :
413+ et_program .write_tensor_data_to_file (output_dir )
414+ print (f" Saved tensor data (.ptd) to { output_dir } /" )
415+ print ("Done." )
416+
417+
418+ def _validate_backend_flags (p , args ) -> None :
419+ if args .share_draft_embedding and args .backend != "mlx" :
420+ p .error ("--share-draft-embedding is only supported with --backend mlx." )
421+ if args .quantize_draft and args .backend != "mlx" :
422+ p .error ("--quantize-draft is only supported with --backend mlx." )
286423
287- print (f"Loading { args .target_model } target from { args .target } ..." )
288- target = spec_t .load (args .target , args .max_seq_len )
424+
425+ def _load_target_and_draft (p , args , spec_t ):
426+ print (f"Loading { args .target_model } target ({ args .backend } ) from { args .target } ..." )
427+ target = spec_t .load (args .target , args .max_seq_len , args .backend )
289428
290429 print (f"Loading draft head from { args .draft } ..." )
291430 draft , _ = Eagle3Draft .from_checkpoint (
@@ -308,12 +447,38 @@ def main() -> None:
308447 f"[{ int (target_ids .min ())} , { int (target_ids .max ())} ]; the draft and "
309448 f"target are likely not a matched pair"
310449 )
450+ return target , draft
451+
311452
453+ def _run_mlx (p , args , target , draft , max_prefill , verify_len ) -> None :
454+ if args .quantize_draft :
455+ from executorch .examples .models .eagle3 .quant_mlx import (
456+ quantize_pack_draft_for_mlx ,
457+ )
458+
459+ print ("Quantizing + packing draft for MLX (int4)..." )
460+ draft = quantize_pack_draft_for_mlx (draft )
461+ else :
462+ print ("Keeping draft in bf16 (pass --quantize-draft for int4)..." )
463+ # MLX builds attention masks internally, so a single forward accepts T>=1.
464+ if max_prefill < verify_len :
465+ p .error (
466+ f"computed max_prefill={ max_prefill } < verify window { verify_len } ; "
467+ f"raise --max-prefill (got { args .max_prefill } ) or --max-seq-len "
468+ f"(got { args .max_seq_len } )"
469+ )
312470 spec = Eagle3Speculator (target , draft ).eval ()
471+ _export_mlx (
472+ spec ,
473+ args .output_dir ,
474+ max_prefill = max_prefill ,
475+ chain_len = args .chain ,
476+ share_base_embedding = args .share_draft_embedding ,
477+ )
313478
314- # A single target forward accepts min_forward_len .. max_forward_len tokens.
315- max_forward = spec_t . max_forward_len ( target . config )
316- max_prefill = min ( args . max_prefill , args . max_seq_len - 1 , max_forward )
479+
480+ def _run_cuda ( p , args , spec_t , target , draft , max_prefill , verify_len ) -> None :
481+ spec = Eagle3Speculator ( target , draft ). eval ( )
317482 # prefill's dynamic min (see _export_cuda target_min): the target's own
318483 # specialization (min_forward_len) and the INT4 dispatch (> MATVEC_MAX_M).
319484 prefill_min = max (spec_t .min_forward_len , _MATVEC_MAX_M + 1 )
@@ -324,9 +489,7 @@ def main() -> None:
324489 f"{ args .max_seq_len } )"
325490 )
326491 # target_verify is a single static forward of chain+1 tokens: it must fit the
327- # small-M GEMM (chain+1 <= _MATVEC_MAX_M) and the target's per-forward bounds
328- # [min_forward_len, max_forward].
329- verify_len = args .chain + 1
492+ # small-M GEMM (chain+1 <= _MATVEC_MAX_M) and the target's minimum forward.
330493 if verify_len > _MATVEC_MAX_M :
331494 p .error (
332495 f"--chain { args .chain } (verify window { verify_len } ) exceeds the "
@@ -337,14 +500,8 @@ def main() -> None:
337500 f"--chain { args .chain } (verify window { verify_len } ) is below the "
338501 f"target's minimum forward length { spec_t .min_forward_len } "
339502 )
340- if verify_len > min (args .max_seq_len - 1 , max_forward ):
341- p .error (
342- f"--chain { args .chain } (verify window { verify_len } ) exceeds the "
343- f"target's per-forward limit { min (args .max_seq_len - 1 , max_forward )} "
344- )
345503 # Route the static chain_len+1 verify forward to the small-M INT4 GEMM by
346- # raising the dispatch threshold for this export only; restore it so the
347- # process-global default (4) is unchanged for any later use.
504+ # raising the dispatch threshold for this export only; restore it after.
348505 import executorch .backends .cuda .int4_dispatch as int4_dispatch
349506
350507 saved_threshold = int4_dispatch .MATVEC_MAX_M
@@ -361,5 +518,75 @@ def main() -> None:
361518 int4_dispatch .MATVEC_MAX_M = saved_threshold
362519
363520
521+ def main () -> None :
522+ p = argparse .ArgumentParser (description = "Export an EAGLE-3 speculator to .pte." )
523+ p .add_argument (
524+ "--target-model" ,
525+ default = "gemma4_31b" ,
526+ choices = list (TARGETS ),
527+ help = "Registered target model (see eagle3/target.py)." ,
528+ )
529+ p .add_argument (
530+ "--target" , required = True , help = "Prequantized (INT4) target directory."
531+ )
532+ p .add_argument ("--draft" , required = True , help = "EAGLE-3 draft head directory." )
533+ p .add_argument ("--output-dir" , default = "./eagle3_exports" )
534+ p .add_argument ("--max-seq-len" , type = int , default = 4096 )
535+ p .add_argument (
536+ "--max-prefill" ,
537+ type = int ,
538+ default = 512 ,
539+ help = "Max prefill length: AOTI compiles prefill kernels for up to this T "
540+ "and the whole prompt must fit in one prefill (the runner does not chunk). "
541+ "Smaller compiles faster." ,
542+ )
543+ p .add_argument (
544+ "--chain" , type = int , default = 4 , help = "Draft chain length K (verify K+1)."
545+ )
546+ p .add_argument (
547+ "--backend" ,
548+ default = "cuda" ,
549+ choices = ["cuda" , "mlx" ],
550+ help = "Target backend: cuda (AOTI, INT4 target, bf16 draft) or mlx "
551+ "(Apple silicon, INT4 target; bf16 draft, --quantize-draft for int4)." ,
552+ )
553+ p .add_argument (
554+ "--share-draft-embedding" ,
555+ action = "store_true" ,
556+ help = "MLX only: reuse the target's packed embedding for the draft so the "
557+ "NamedDataStore dedups it to one copy (drops the draft's own embedding)." ,
558+ )
559+ p .add_argument (
560+ "--quantize-draft" ,
561+ action = "store_true" ,
562+ help = "MLX only: int4-pack the draft head (default keeps it bf16). bf16 "
563+ "gives higher acceptance but a larger draft; pair with "
564+ "--share-draft-embedding to avoid a separate draft embedding copy." ,
565+ )
566+ args = p .parse_args ()
567+ _validate_backend_flags (p , args )
568+
569+ spec_t = TARGETS [args .target_model ]
570+ if args .backend == "cuda" and not torch .cuda .is_available ():
571+ p .error ("CUDA is required to compile the CUDA EAGLE-3 export." )
572+
573+ target , draft = _load_target_and_draft (p , args , spec_t )
574+
575+ # A single target forward accepts up to max_forward_len tokens.
576+ max_forward = spec_t .max_forward_len (target .config )
577+ max_prefill = min (args .max_prefill , args .max_seq_len - 1 , max_forward )
578+ verify_len = args .chain + 1
579+ if verify_len > min (args .max_seq_len - 1 , max_forward ):
580+ p .error (
581+ f"--chain { args .chain } (verify window { verify_len } ) exceeds the "
582+ f"target's per-forward limit { min (args .max_seq_len - 1 , max_forward )} "
583+ )
584+
585+ if args .backend == "mlx" :
586+ _run_mlx (p , args , target , draft , max_prefill , verify_len )
587+ else :
588+ _run_cuda (p , args , spec_t , target , draft , max_prefill , verify_len )
589+
590+
364591if __name__ == "__main__" :
365592 main ()
0 commit comments