Skip to content

Commit 2ebb25d

Browse files
committed
Add MTP self speculative generation
1 parent a7f8fa1 commit 2ebb25d

5 files changed

Lines changed: 421 additions & 3 deletions

File tree

cppmega_mlx/inference/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
GenerationChunk,
1717
build_prompt_cache,
1818
generate_tokens,
19+
generate_tokens_mtp_self_speculative,
1920
generate_tokens_speculative,
2021
generate_tokens_with_prompt_cache,
2122
generate_tokens_with_kv_cache,
@@ -97,6 +98,7 @@
9798
"builtin_code_metadata_adapters",
9899
"clone_contiguous_kv_cache",
99100
"generate_tokens",
101+
"generate_tokens_mtp_self_speculative",
100102
"generate_tokens_speculative",
101103
"generate_tokens_with_prompt_cache",
102104
"generate_tokens_with_kv_cache",

cppmega_mlx/inference/generation.py

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,104 @@ def generate_tokens_speculative(
247247
return tokens
248248

249249

250+
def generate_tokens_mtp_self_speculative(
251+
model: Any,
252+
prompt_ids: mx.array,
253+
*,
254+
max_new_tokens: int,
255+
draft_window: int | None = None,
256+
model_kwargs: Mapping[str, mx.array] | None = None,
257+
eos_token_id: int | None = None,
258+
temperature: float = 1.0,
259+
rng_key: Any | None = None,
260+
) -> mx.array:
261+
"""Generate with a model-owned MTP head as the speculative drafter.
262+
263+
This is the scoped FastMTP-aligned Stream I path: the same model computes
264+
the last verified hidden state, its attached ``mtp_head`` drafts a bounded
265+
token window, and the model verifies the candidate prefix with one normal
266+
target forward. It is deliberately eager batch=1 and no-KV.
267+
"""
268+
269+
if len(prompt_ids.shape) != 2:
270+
raise ValueError("prompt_ids must have shape (batch, sequence)")
271+
if int(prompt_ids.shape[0]) != 1:
272+
raise ValueError("MTP self-speculative generation currently supports batch=1")
273+
if int(prompt_ids.shape[1]) <= 0:
274+
raise ValueError("prompt_ids must contain at least one token")
275+
if max_new_tokens < 0:
276+
raise ValueError("max_new_tokens must be non-negative")
277+
if max_new_tokens == 0:
278+
return prompt_ids
279+
280+
mtp_head = _resolve_mtp_draft_head(model)
281+
trained_depth = _mtp_head_trained_depth(mtp_head)
282+
window_limit = trained_depth if draft_window is None else draft_window
283+
if window_limit <= 0:
284+
raise ValueError("draft_window must be positive")
285+
if window_limit > trained_depth:
286+
raise ValueError("draft_window must not exceed model.mtp_head.config.depth")
287+
288+
max_seq_length = _model_max_seq_length(model)
289+
_validate_prefix_fits_max_length(prompt_ids, max_seq_length)
290+
291+
tokens = prompt_ids
292+
generated = 0
293+
key = rng_key
294+
while generated < max_new_tokens:
295+
remaining = max_new_tokens - generated
296+
append_limit = remaining
297+
window = min(window_limit, remaining)
298+
target_slots = _available_generation_slots(tokens, max_seq_length)
299+
if target_slots is not None:
300+
window = min(window, target_slots)
301+
append_limit = min(append_limit, target_slots)
302+
303+
draft_tokens, draft_logits, key = _propose_mtp_self_speculative_window(
304+
model,
305+
mtp_head,
306+
tokens,
307+
draft_window=window,
308+
model_kwargs=model_kwargs,
309+
eos_token_id=eos_token_id,
310+
temperature=temperature,
311+
rng_key=key,
312+
)
313+
candidate = mx.concatenate([tokens, draft_tokens[None, :].astype(tokens.dtype)], axis=1)
314+
_validate_prefix_fits_max_length(candidate, max_seq_length)
315+
316+
target_logits = _standard_generation_logits(
317+
model(
318+
candidate,
319+
**_model_kwargs_for_prefix(model_kwargs, candidate),
320+
),
321+
candidate,
322+
)
323+
start = int(tokens.shape[1]) - 1
324+
verifier_logits = target_logits[0, start : start + int(draft_tokens.shape[0]) + 1, :]
325+
326+
key, acceptance_key = _split_generation_key(key)
327+
accepted, _n_accepted, next_token = speculative_acceptance(
328+
draft_logits,
329+
verifier_logits,
330+
draft_tokens,
331+
temperature=temperature,
332+
rng_key=acceptance_key,
333+
)
334+
append_tokens, found_eos = _speculative_append_tokens(
335+
accepted,
336+
next_token,
337+
remaining=append_limit,
338+
eos_token_id=eos_token_id,
339+
)
340+
tokens = mx.concatenate([tokens, append_tokens[None, :].astype(tokens.dtype)], axis=1)
341+
generated += int(append_tokens.shape[0])
342+
if found_eos:
343+
break
344+
345+
return tokens
346+
347+
250348
def generate_tokens_with_kv_cache(
251349
model: Any,
252350
prompt_ids: mx.array,
@@ -874,6 +972,147 @@ def _speculative_append_tokens(
874972
return proposed, False
875973

876974

975+
def _resolve_mtp_draft_head(model: Any) -> Any:
976+
mtp_head = getattr(model, "mtp_head", None)
977+
if mtp_head is None:
978+
raise ValueError("model.mtp_head is required for MTP self-speculative generation")
979+
if not callable(getattr(model, "decoder_hidden_states", None)):
980+
raise TypeError("MTP self-speculative generation requires model.decoder_hidden_states")
981+
return mtp_head
982+
983+
984+
def _mtp_head_trained_depth(mtp_head: Any) -> int:
985+
config = getattr(mtp_head, "config", None)
986+
depth = getattr(config, "depth", None)
987+
if depth is None:
988+
raise ValueError("model.mtp_head.config.depth is required")
989+
depth_int = int(depth)
990+
if depth_int <= 0:
991+
raise ValueError("model.mtp_head.config.depth must be positive")
992+
return depth_int
993+
994+
995+
def _propose_mtp_self_speculative_window(
996+
model: Any,
997+
mtp_head: Any,
998+
tokens: mx.array,
999+
*,
1000+
draft_window: int,
1001+
model_kwargs: Mapping[str, mx.array] | None,
1002+
eos_token_id: int | None,
1003+
temperature: float,
1004+
rng_key: Any | None,
1005+
) -> tuple[mx.array, mx.array, Any | None]:
1006+
hidden_states = model.decoder_hidden_states(
1007+
tokens,
1008+
**_model_kwargs_for_prefix(model_kwargs, tokens),
1009+
)
1010+
if not isinstance(hidden_states, mx.array) or hidden_states.ndim != 3:
1011+
raise TypeError("model.decoder_hidden_states must return (batch, sequence, hidden)")
1012+
if hidden_states.shape[:2] != tokens.shape:
1013+
raise ValueError("decoder hidden states prefix shape must match tokens")
1014+
1015+
last_hidden = hidden_states[:, -1:, :]
1016+
last_token_ids = tokens[:, -1:]
1017+
draft_method = getattr(mtp_head, "draft", None)
1018+
if callable(draft_method):
1019+
draft_callable = cast(
1020+
Callable[..., tuple[mx.array, mx.array, Any | None]],
1021+
draft_method,
1022+
)
1023+
draft_rows, draft_logit_rows, key = draft_callable(
1024+
last_hidden,
1025+
last_token_ids,
1026+
num_draft_tokens=draft_window,
1027+
temperature=temperature,
1028+
rng_key=rng_key,
1029+
)
1030+
else:
1031+
draft_rows, draft_logit_rows, key = _draft_with_minimal_mtp_head(
1032+
mtp_head,
1033+
last_hidden,
1034+
last_token_ids,
1035+
num_draft_tokens=draft_window,
1036+
temperature=temperature,
1037+
rng_key=rng_key,
1038+
)
1039+
_validate_mtp_draft_rows(draft_rows, draft_logit_rows, draft_window=draft_window)
1040+
1041+
draft_tokens = draft_rows[0].astype(tokens.dtype)
1042+
draft_logits = draft_logit_rows[0]
1043+
if eos_token_id is not None:
1044+
for idx in range(int(draft_tokens.shape[0])):
1045+
if int(draft_tokens[idx].item()) == eos_token_id:
1046+
return draft_tokens[: idx + 1], draft_logits[: idx + 1], key
1047+
return draft_tokens, draft_logits, key
1048+
1049+
1050+
def _draft_with_minimal_mtp_head(
1051+
mtp_head: Any,
1052+
last_hidden: mx.array,
1053+
last_token_ids: mx.array,
1054+
*,
1055+
num_draft_tokens: int,
1056+
temperature: float,
1057+
rng_key: Any | None,
1058+
) -> tuple[mx.array, mx.array, Any | None]:
1059+
required = (
1060+
"token_embedding",
1061+
"hidden_norm",
1062+
"embedding_norm",
1063+
"proj",
1064+
"shared_block",
1065+
"output_norm",
1066+
"lm_head",
1067+
)
1068+
missing = [name for name in required if not callable(getattr(mtp_head, name, None))]
1069+
if missing:
1070+
raise TypeError(f"model.mtp_head is missing draft components: {', '.join(missing)}")
1071+
1072+
h = last_hidden
1073+
token_ids = last_token_ids
1074+
draft_tokens: list[mx.array] = []
1075+
draft_logits: list[mx.array] = []
1076+
key = rng_key
1077+
for _ in range(num_draft_tokens):
1078+
token_emb = mtp_head.token_embedding(token_ids)
1079+
h_mtp = mtp_head.proj(
1080+
mx.concatenate(
1081+
[mtp_head.hidden_norm(h), mtp_head.embedding_norm(token_emb)],
1082+
axis=-1,
1083+
)
1084+
)
1085+
h = mtp_head.output_norm(mtp_head.shared_block(h_mtp))
1086+
step_logits = mtp_head.lm_head(h)[:, -1, :]
1087+
key, step_key = _split_generation_key(key)
1088+
next_token = sample_next_token(
1089+
step_logits,
1090+
temperature=temperature,
1091+
rng_key=step_key,
1092+
).astype(last_token_ids.dtype)
1093+
draft_logits.append(step_logits)
1094+
draft_tokens.append(next_token)
1095+
token_ids = next_token
1096+
1097+
return mx.concatenate(draft_tokens, axis=1), mx.stack(draft_logits, axis=1), key
1098+
1099+
1100+
def _validate_mtp_draft_rows(
1101+
draft_tokens: mx.array,
1102+
draft_logits: mx.array,
1103+
*,
1104+
draft_window: int,
1105+
) -> None:
1106+
if not isinstance(draft_tokens, mx.array):
1107+
raise TypeError("MTP draft tokens must be an mlx.core.array")
1108+
if not isinstance(draft_logits, mx.array):
1109+
raise TypeError("MTP draft logits must be an mlx.core.array")
1110+
if draft_tokens.shape != (1, draft_window):
1111+
raise ValueError("MTP draft tokens must have shape (1, draft_window)")
1112+
if len(draft_logits.shape) != 3 or draft_logits.shape[:2] != (1, draft_window):
1113+
raise ValueError("MTP draft logits must have shape (1, draft_window, vocab)")
1114+
1115+
8771116
def _validate_prefix_fits_max_length(tokens: mx.array, max_seq_length: int | None) -> None:
8781117
if max_seq_length is not None and tokens.shape[1] > max_seq_length:
8791118
raise ValueError("tokens exceed model.config.max_seq_length")

docs/mlx_port_master_plan.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ This document is a planning synthesis from parallel research lanes (local-code a
4343
- **Sharded checkpoints** (model.safetensors.index.json).
4444
- **Sequence packing** (cumulative-doc-id attention mask).
4545
- **Tokenizer**: M0.1 is closed. The vendored GB10 BPE artifact and special-token contract exist for vocab=65536 with id7=<CODE_START>, id45=<FIM_INSTRUCTION>, id46=<SPACE>, id47=<NL>. Encode normalizes whitespace runs ([\r\n]+-><NL>, [ \t]+-><SPACE>); decode is plain token concat with sentinel substitution. Same wrapper deployed on Mac and gb10/CUDA side.
46-
- **Inference / generation**: temperature/top-k/top-p sampling, eager no-KV full-prefix generation, contiguous KV-cache generation/streaming, paged KV scheduler metadata with fail-closed model-integration guard, contiguous prompt-cache reuse, fail-closed standard MTP/draft-output guard, and prompt-only FIM/iFIM infilling construction exist locally. Still open: model-integrated paged attention/API serving, sliding-window/SSM prompt-cache safety, integrated FIM decode/postprocess loop, speculative/self-spec decode, q4 path, and KV-q4 path.
46+
- **Inference / generation**: temperature/top-k/top-p sampling, eager no-KV full-prefix generation, contiguous KV-cache generation/streaming, paged KV scheduler metadata with fail-closed model-integration guard, contiguous prompt-cache reuse, fail-closed standard MTP/draft-output guard, prompt-only FIM/iFIM infilling construction, eager vanilla speculative decoding, and eager MTP self-speculative decoding exist locally. Still open: model-integrated paged attention/API serving, sliding-window/SSM prompt-cache safety, integrated FIM decode/postprocess loop, EAGLE/token-recycling speculative paths, q4 path, and KV-q4 path.
4747
- **Quantization**: bf16 only; no mx.quantize integration, no q4 inference path.
4848
- **Structural parity anchors, not CUDA tensor parity**: existing tests/test_cppmega_parity_anchors.py checks NAM56R route/layer constants, DSA/MLA layer derivation, vocab/MoE anchors, fail-closed parity wording, and optional sibling cppmega source-anchor presence when that checkout exists. The source-file existence check is only one guard; the test is broader than file-presence coverage, but it still does not compare CUDA golden tensors or prove numerical agreement.
4949

@@ -68,7 +68,7 @@ Full-stack research-grade LLM (40-45K LOC):
6868
- fim.py (Bavarian 2207.14255 PSM/SPM, plus AST-FIM 2506.00204).
6969
- ifim.py (Sun et al. arXiv 2509.24637, Sep 2025: instruction-aware FIM; paper/source proposal only, not the deployed GB10 token-id contract).
7070
- stp.py (Huang/LeCun arXiv 2602.22617, Feb 2026: JEPA geodesic regularization, ~100 LOC).
71-
- Speculative decoding (acceptance-rejection), EAGLE-2 draft head.
71+
- Speculative decoding references: vanilla acceptance-rejection and MTP self-spec now have scoped eager MLX loops; EAGLE-2 remains pattern-only/gated until simpler paths underperform.
7272
- Tokenizer/FIM intent is a source reference only: live local/GB10 artifacts use the deployed M0.1 contract documented below (id7=<CODE_START>, id45=<FIM_INSTRUCTION>, id46=<SPACE>, id47=<NL>), and M0.1 decode parity is covered by the explicit <SPACE>/<NL> token redesign rather than HF reversible round-trip decode.
7373
- Training: pretrain + midtrain + SFT + RL via Muon/AdamW, FA3 (CUDA) / Pallas (TPU) / SDPA fallback.
7474
- Inference: contiguous KV (engine.py) + paged KV scheduler (serving.py).
@@ -884,7 +884,7 @@ Excluded as Hopper-only dead-end on Metal: cppmega/megatron/cute_dsl_mimo/ (sm_9
884884
166. **SCOPED DONE**: add prompt-only FIM-aware infilling in cppmega_mlx/inference/infilling.py (PSM/SPM token routing plus iFIM id45 prefix). This constructs inference prompts ending at <FIM_MIDDLE> only; it does not append the target middle/EOT, run generation, post-process the generated span, or add a KV/paged decode loop.
885885
167. **SCOPED DONE**: add `generate_tokens_speculative(...)` in `cppmega_mlx/inference/generation.py`, exported from `cppmega_mlx.inference`. The eager batch=1 loop lets a draft model propose a bounded token window, verifies with one target-model full-prefix forward over K+1 logits, and applies the existing Leviathan-style acceptance-rejection helper before appending accepted tokens plus target fallback/tail. This is greenfield MLX work because cppmega CUDA does not implement speculative decoding. It is not KV-cache/paged speculative serving, EAGLE-2, MTP self-speculation, throughput/quality benchmarking, or GB10/CUDA parity.
886886
168. Add EAGLE-2-style draft head (separate small draft model, GQA-aware) referencing nanochat/experiments/sota_impl/eagle2/. Gated; defer if MTP self-speculation already meets the throughput target.
887-
169. Add self-speculative decoding via MTP (FastMTP-aligned: same model emits draft tokens via its MTP heads, target verifies in one extra forward) referencing nanochat/mtp_draft.py. Cheapest of the three since MTP head already lands in Stream H; benchmark first.
887+
169. **SCOPED DONE**: add `generate_tokens_mtp_self_speculative(...)` in `cppmega_mlx/inference/generation.py`, exported from `cppmega_mlx.inference`. The eager batch=1 loop uses the model-owned `mtp_head` to draft a bounded K-token window from the last verified hidden state, verifies the candidate prefix with one normal target forward over K+1 logits, and reuses the Leviathan-style acceptance-rejection helper. It supports the real `MinimalMTPHead` fallback plus explicit `mtp_head.draft(...)` adapters, and fails closed when no trained MTP head is attached or the requested draft window exceeds `mtp_head.config.depth`. This is not KV-cache/paged self-speculative serving, EAGLE-2, token recycling, throughput/quality benchmarking, or GB10/CUDA parity.
888888
170. **SCOPED DONE (local streaming slice)**: add `GenerationChunk` and `stream_generate_tokens(...)` in `cppmega_mlx/inference/generation.py`, exported from `cppmega_mlx.inference`. Covered eager full-prefix and contiguous-KV token streaming, batched rows, all-rows EOS stopping, optional per-token text decode, zero-new-token no-op, package export boundary, quantized-KV fail-closed behavior, and paged-attention non-integration guard. This is not full mlx-lm registry export, model-integrated paged attention, prompt cache, q4/KV-q4, speculative/self-spec decoding, OpenAI serving, throughput/quality/long-context benching, or GB10/CUDA parity.
889889
171. **SCOPED DONE (contiguous prompt-cache reuse)**: add PromptCacheEntry, clone_contiguous_kv_cache(...), build_prompt_cache(...), and generate_tokens_with_prompt_cache(...) for repeated-prefix reuse on the Mac-local contiguous KV path. Covered build/reuse, suffix decode, prefix mismatch fail-closed behavior, zero-new-token no-op, package exports, independent cache cloning, and real HybridTinyLM prompt-cache-vs-full-KV parity. This is not paged attention, OpenAI serving, sliding-window/SSM safety, q4/KV-q4, speculative decode, or CUDA/H200 parity.
890890
172. Validate prompt-cache safety with sliding-window/SSM against the installed mlx-lm prompt-cache APIs; do not assume a generic KV cache is safe for every route.

0 commit comments

Comments
 (0)