Skip to content

Commit eff6821

Browse files
committed
up
1 parent c64fc52 commit eff6821

6 files changed

Lines changed: 769 additions & 50 deletions

File tree

examples/models/eagle3/export.py

Lines changed: 272 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,20 @@
2727
(``test_shifted_speculative_decode_is_lossless`` drives the full loop through only
2828
these 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
3645
Scope (this is a fixed-shape ExecuTorch artifact, not a generic EAGLE runtime):
3746
chain 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+
98136
def _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+
364591
if __name__ == "__main__":
365592
main()

0 commit comments

Comments
 (0)