Skip to content

Commit a7f8fa1

Browse files
committed
Add eager speculative generation loop
1 parent b97e940 commit a7f8fa1

5 files changed

Lines changed: 405 additions & 16 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_speculative,
1920
generate_tokens_with_prompt_cache,
2021
generate_tokens_with_kv_cache,
2122
next_token_logits,
@@ -96,6 +97,7 @@
9697
"builtin_code_metadata_adapters",
9798
"clone_contiguous_kv_cache",
9899
"generate_tokens",
100+
"generate_tokens_speculative",
99101
"generate_tokens_with_prompt_cache",
100102
"generate_tokens_with_kv_cache",
101103
"get_builtin_code_metadata_adapter",

cppmega_mlx/inference/generation.py

Lines changed: 199 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
make_contiguous_kv_cache,
1818
)
1919
from cppmega_mlx.inference.sampling import sample_next_token
20+
from cppmega_mlx.inference.speculative_decode import speculative_acceptance
2021

2122
GenerationFinishReason = Literal["eos", "length"]
2223

@@ -78,21 +79,7 @@ def next_token_logits(model_output: Any, tokens: mx.array) -> mx.array:
7879
MTP/draft tensors are rejected until the speculative/self-spec paths land.
7980
"""
8081

81-
if isinstance(model_output, tuple | list):
82-
raise ValueError(
83-
"MTP/draft tuple outputs are not supported by standard next-token "
84-
"inference"
85-
)
86-
if isinstance(model_output, dict):
87-
raise ValueError(
88-
"structured model outputs are not supported by standard next-token "
89-
"inference; pass plain logits"
90-
)
91-
if not isinstance(model_output, mx.array):
92-
raise TypeError("model output must be an mlx.core.array of logits")
93-
94-
_validate_logits_shape(model_output, tokens)
95-
return model_output[:, -1, :]
82+
return _standard_generation_logits(model_output, tokens)[:, -1, :]
9683

9784

9885
def generate_tokens(
@@ -157,6 +144,109 @@ def generate_tokens(
157144
return tokens
158145

159146

147+
def generate_tokens_speculative(
148+
target_model: Any,
149+
draft_model: Any,
150+
prompt_ids: mx.array,
151+
*,
152+
max_new_tokens: int,
153+
draft_window: int = 4,
154+
model_kwargs: Mapping[str, mx.array] | None = None,
155+
draft_model_kwargs: Mapping[str, mx.array] | None = None,
156+
eos_token_id: int | None = None,
157+
temperature: float = 1.0,
158+
rng_key: Any | None = None,
159+
) -> mx.array:
160+
"""Generate with vanilla speculative decoding on the eager MLX path.
161+
162+
The draft model proposes up to ``draft_window`` tokens, then the target
163+
model verifies those tokens with one full-prefix forward and the existing
164+
Leviathan-style acceptance-rejection helper. This scoped Stream I slice is
165+
deliberately batch=1 and no-KV; paged attention, EAGLE, and MTP
166+
self-speculation remain separate rows.
167+
"""
168+
169+
if len(prompt_ids.shape) != 2:
170+
raise ValueError("prompt_ids must have shape (batch, sequence)")
171+
if int(prompt_ids.shape[0]) != 1:
172+
raise ValueError("speculative generation currently supports batch=1")
173+
if int(prompt_ids.shape[1]) <= 0:
174+
raise ValueError("prompt_ids must contain at least one token")
175+
if max_new_tokens < 0:
176+
raise ValueError("max_new_tokens must be non-negative")
177+
if draft_window <= 0:
178+
raise ValueError("draft_window must be positive")
179+
if max_new_tokens == 0:
180+
return prompt_ids
181+
182+
target_max_seq_length = _model_max_seq_length(target_model)
183+
draft_max_seq_length = _model_max_seq_length(draft_model)
184+
_validate_prefix_fits_max_length(prompt_ids, target_max_seq_length)
185+
_validate_prefix_fits_max_length(prompt_ids, draft_max_seq_length)
186+
187+
tokens = prompt_ids
188+
generated = 0
189+
key = rng_key
190+
resolved_draft_model_kwargs = draft_model_kwargs
191+
if resolved_draft_model_kwargs is None:
192+
resolved_draft_model_kwargs = model_kwargs
193+
194+
while generated < max_new_tokens:
195+
remaining = max_new_tokens - generated
196+
append_limit = remaining
197+
window = min(draft_window, remaining)
198+
target_slots = _available_generation_slots(tokens, target_max_seq_length)
199+
if target_slots is not None:
200+
window = min(window, target_slots)
201+
append_limit = min(append_limit, target_slots)
202+
draft_slots = _available_generation_slots(tokens, draft_max_seq_length)
203+
if draft_slots is not None:
204+
window = min(window, draft_slots)
205+
draft_tokens, draft_logits, key = _propose_speculative_draft_window(
206+
draft_model,
207+
tokens,
208+
draft_window=window,
209+
model_kwargs=resolved_draft_model_kwargs,
210+
eos_token_id=eos_token_id,
211+
temperature=temperature,
212+
rng_key=key,
213+
max_seq_length=draft_max_seq_length,
214+
)
215+
candidate = mx.concatenate([tokens, draft_tokens[None, :].astype(tokens.dtype)], axis=1)
216+
_validate_prefix_fits_max_length(candidate, target_max_seq_length)
217+
218+
target_logits = _standard_generation_logits(
219+
target_model(
220+
candidate,
221+
**_model_kwargs_for_prefix(model_kwargs, candidate),
222+
),
223+
candidate,
224+
)
225+
start = int(tokens.shape[1]) - 1
226+
verifier_logits = target_logits[0, start : start + int(draft_tokens.shape[0]) + 1, :]
227+
228+
key, acceptance_key = _split_generation_key(key)
229+
accepted, _n_accepted, next_token = speculative_acceptance(
230+
draft_logits,
231+
verifier_logits,
232+
draft_tokens,
233+
temperature=temperature,
234+
rng_key=acceptance_key,
235+
)
236+
append_tokens, found_eos = _speculative_append_tokens(
237+
accepted,
238+
next_token,
239+
remaining=append_limit,
240+
eos_token_id=eos_token_id,
241+
)
242+
tokens = mx.concatenate([tokens, append_tokens[None, :].astype(tokens.dtype)], axis=1)
243+
generated += int(append_tokens.shape[0])
244+
if found_eos:
245+
break
246+
247+
return tokens
248+
249+
160250
def generate_tokens_with_kv_cache(
161251
model: Any,
162252
prompt_ids: mx.array,
@@ -557,6 +647,24 @@ def _resolve_kv_cache(
557647
)
558648

559649

650+
def _standard_generation_logits(model_output: Any, tokens: mx.array) -> mx.array:
651+
if isinstance(model_output, tuple | list):
652+
raise ValueError(
653+
"MTP/draft tuple outputs are not supported by standard next-token "
654+
"inference"
655+
)
656+
if isinstance(model_output, dict):
657+
raise ValueError(
658+
"structured model outputs are not supported by standard next-token "
659+
"inference; pass plain logits"
660+
)
661+
if not isinstance(model_output, mx.array):
662+
raise TypeError("model output must be an mlx.core.array of logits")
663+
664+
_validate_logits_shape(model_output, tokens)
665+
return model_output
666+
667+
560668
def _model_kwargs_for_prefix(
561669
model_kwargs: Mapping[str, mx.array] | None,
562670
tokens: mx.array,
@@ -704,6 +812,82 @@ def _validate_prompt_cache_prefix(
704812
raise ValueError("prompt_ids must start with prompt_cache.prompt_ids")
705813

706814

815+
def _propose_speculative_draft_window(
816+
draft_model: Any,
817+
tokens: mx.array,
818+
*,
819+
draft_window: int,
820+
model_kwargs: Mapping[str, mx.array] | None,
821+
eos_token_id: int | None,
822+
temperature: float,
823+
rng_key: Any | None,
824+
max_seq_length: int | None,
825+
) -> tuple[mx.array, mx.array, Any | None]:
826+
draft_tokens: list[mx.array] = []
827+
draft_logits: list[mx.array] = []
828+
draft_prefix = tokens
829+
key = rng_key
830+
831+
for _ in range(draft_window):
832+
if max_seq_length is not None and draft_prefix.shape[1] >= max_seq_length:
833+
raise ValueError("draft generation would exceed model.config.max_seq_length")
834+
step_logits = next_token_logits(
835+
draft_model(
836+
draft_prefix,
837+
**_model_kwargs_for_prefix(model_kwargs, draft_prefix),
838+
),
839+
draft_prefix,
840+
)
841+
key, step_key = _split_generation_key(key)
842+
next_token = sample_next_token(
843+
step_logits,
844+
temperature=temperature,
845+
rng_key=step_key,
846+
).astype(tokens.dtype)
847+
draft_logits.append(step_logits[0])
848+
draft_tokens.append(next_token[:, 0])
849+
draft_prefix = mx.concatenate([draft_prefix, next_token], axis=1)
850+
if eos_token_id is not None and _all_rows_match_token(next_token, eos_token_id):
851+
break
852+
853+
return (
854+
mx.concatenate(draft_tokens, axis=0).astype(tokens.dtype),
855+
mx.stack(draft_logits, axis=0),
856+
key,
857+
)
858+
859+
860+
def _speculative_append_tokens(
861+
accepted: mx.array,
862+
next_token: mx.array,
863+
*,
864+
remaining: int,
865+
eos_token_id: int | None,
866+
) -> tuple[mx.array, bool]:
867+
proposed = mx.concatenate([accepted, next_token], axis=0)[:remaining]
868+
if eos_token_id is None:
869+
return proposed, False
870+
871+
for idx in range(int(proposed.shape[0])):
872+
if int(proposed[idx].item()) == eos_token_id:
873+
return proposed[: idx + 1], True
874+
return proposed, False
875+
876+
877+
def _validate_prefix_fits_max_length(tokens: mx.array, max_seq_length: int | None) -> None:
878+
if max_seq_length is not None and tokens.shape[1] > max_seq_length:
879+
raise ValueError("tokens exceed model.config.max_seq_length")
880+
881+
882+
def _available_generation_slots(tokens: mx.array, max_seq_length: int | None) -> int | None:
883+
if max_seq_length is None:
884+
return None
885+
slots = max_seq_length - int(tokens.shape[1])
886+
if slots <= 0:
887+
raise ValueError("generation would exceed model.config.max_seq_length")
888+
return slots
889+
890+
707891
def _stream_generate_tokens_with_kv_cache(
708892
model: Any,
709893
prompt_ids: mx.array,

docs/mlx_port_master_plan.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -882,7 +882,7 @@ Excluded as Hopper-only dead-end on Metal: cppmega/megatron/cute_dsl_mimo/ (sm_9
882882
164. **SCOPED DONE**: add eager full-prefix no-KV generate_tokens(...) in cppmega_mlx/inference/generation.py, including temp=0 greedy decode; KV cache, paged serving, streaming, prompt cache, integrated FIM decode loop, q4/KV-q4, and speculative decode remain open.
883883
165. **SCOPED DONE**: add next_token_logits(...) standard-decode guard in cppmega_mlx/inference/generation.py; plain (batch, sequence, vocab) logits return the final-position logits, while tuple/list/dict structured outputs and 4D MTP/draft logits fail closed. This is not MTP self-speculation; row 169 remains open.
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.
885-
167. Implement vanilla speculative decoding (acceptance-rejection sampling, target-only verifier) referencing nanochat/speculative_decode.py. Note: cppmega CUDA does not implement speculative decoding — this is greenfield work on MLX, not a parity gap.
885+
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.
887887
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.
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.

0 commit comments

Comments
 (0)