|
17 | 17 | make_contiguous_kv_cache, |
18 | 18 | ) |
19 | 19 | from cppmega_mlx.inference.sampling import sample_next_token |
| 20 | +from cppmega_mlx.inference.speculative_decode import speculative_acceptance |
20 | 21 |
|
21 | 22 | GenerationFinishReason = Literal["eos", "length"] |
22 | 23 |
|
@@ -78,21 +79,7 @@ def next_token_logits(model_output: Any, tokens: mx.array) -> mx.array: |
78 | 79 | MTP/draft tensors are rejected until the speculative/self-spec paths land. |
79 | 80 | """ |
80 | 81 |
|
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, :] |
96 | 83 |
|
97 | 84 |
|
98 | 85 | def generate_tokens( |
@@ -157,6 +144,109 @@ def generate_tokens( |
157 | 144 | return tokens |
158 | 145 |
|
159 | 146 |
|
| 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 | + |
160 | 250 | def generate_tokens_with_kv_cache( |
161 | 251 | model: Any, |
162 | 252 | prompt_ids: mx.array, |
@@ -557,6 +647,24 @@ def _resolve_kv_cache( |
557 | 647 | ) |
558 | 648 |
|
559 | 649 |
|
| 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 | + |
560 | 668 | def _model_kwargs_for_prefix( |
561 | 669 | model_kwargs: Mapping[str, mx.array] | None, |
562 | 670 | tokens: mx.array, |
@@ -704,6 +812,82 @@ def _validate_prompt_cache_prefix( |
704 | 812 | raise ValueError("prompt_ids must start with prompt_cache.prompt_ids") |
705 | 813 |
|
706 | 814 |
|
| 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 | + |
707 | 891 | def _stream_generate_tokens_with_kv_cache( |
708 | 892 | model: Any, |
709 | 893 | prompt_ids: mx.array, |
|
0 commit comments