Skip to content

Commit f81a5ac

Browse files
committed
feat(v4): vendor mlx-lm PR #990 MTPModule + SequentialMTPHead adapter
PR #990 adds native MTP speculative decoding to mlx-lm. The Qwen3.5- specific driver (generate.py / sample_utils.py / server.py / cache.py changes) is out of scope, but the MTPModule itself is the reusable "DeepSeek V3 SequentialMTPHead" pattern that any backbone can plug in: e = pre_fc_norm_embedding(embed_tokens(next_token_ids)) h = pre_fc_norm_hidden(hidden_states) fused = fc(concat([e, h], -1)) for layer in layers: fused = layer(fused, mask, cache) return norm(fused) Two landings: 1. cppmega_v4/nn/_external/_mlx_lm_mtp_module_vendored.py — vendored MTPModule + create_attention_mask, refactored to accept a decoder_layer_factory injection (instead of qwen3_5-specific Attention/MLP/MoE imports). This is the *minimum* edit needed to make the module usable inside the v4 plugin without dragging the whole Qwen3.5 model into scope. MIT, © 2025-2026 Apple Inc. 2. cppmega_v4/nn/mtp_speculative_adapter.py — wraps an existing SequentialMTPHead's depth block (or builds a fresh one) and exposes the (hidden, next_token_ids, embed_tokens, cache=None) → fused_h signature that PR #990's driver expects. Lets our training head serve speculative inference without a separate inference-only module. Key bridging: - PR #990 pre_fc_norm_hidden/_embedding ↔ depth_block.hidden_norm/embedding_norm - PR #990 fc(concat) ↔ depth_block.proj - PR #990 layers + final norm ↔ depth_block.transformer + .output_norm Tests (6 new): vendored MTPModule shape contract; adapter built fresh; adapter from existing head reuses depth-0 weights; rejection of depth-0 head; rejection of out-of-range depth index; determinism. v4 suite: 190 passed / 2 skipped.
1 parent c7cf5b2 commit f81a5ac

3 files changed

Lines changed: 294 additions & 0 deletions

File tree

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# Vendored from ml-explore/mlx-lm PR #990 (mlx_lm/models/qwen3_5.py)
2+
# Upstream license: MIT, © 2025-2026 Apple Inc.
3+
#
4+
# Excerpt only — the MTPModule class + its decoder layer. The full PR also
5+
# touches generate.py / sample_utils.py / server.py / cache.py to wire
6+
# speculative decoding end-to-end through mlx_lm.generate; those drivers are
7+
# out of scope for the v4 plugin (we expose the module via our own dispatch).
8+
#
9+
# Public surface used by cppmega_v4:
10+
# - MTPDecoderLayer — full-attention decoder block for the MTP head
11+
# - MTPModule — fuses hidden + next-token embedding via (pre_norm,
12+
# pre_norm, fc, layers, post_norm) — the "DeepSeek
13+
# V3 SequentialMTPHead" pattern that Qwen3.5 inherited.
14+
15+
"""Multi-Token Prediction module (Qwen3.5 native speculative decoding).
16+
17+
Vendored verbatim except for:
18+
- removed unused imports (Attention/MLP/SparseMoeBlock/SwitchLinear) that
19+
pull the whole qwen3_5 model into scope. The MTPModule itself only
20+
constructs sub-layers via *injected* nn.Module classes — callers pass
21+
in the decoder-layer factory rather than relying on the qwen3_5 module
22+
globals.
23+
24+
That refactor (factory pattern) is the *minimum* edit to make the vendored
25+
class usable without dragging Qwen3.5-specific Attention/MLP/MoE code into
26+
the v4 plugin.
27+
"""
28+
29+
from typing import Any, Callable, Optional
30+
31+
import mlx.core as mx
32+
import mlx.nn as nn
33+
34+
35+
def create_attention_mask(x: mx.array, cache: Any) -> Optional[mx.array]:
36+
"""Causal mask helper (lifted from mlx_lm.models.base for self-containment)."""
37+
L = x.shape[1]
38+
if L <= 1:
39+
return None
40+
offset = 0 if cache is None else cache.offset
41+
rinds = mx.arange(offset + L)
42+
linds = mx.arange(offset, offset + L) if offset else rinds
43+
mask = linds[:, None] < rinds[None]
44+
return mask * -1e9
45+
46+
47+
class MTPModule(nn.Module):
48+
"""Multi-Token Prediction head (Qwen3.5 native speculative decoding).
49+
50+
Predicts token t+2 from the backbone hidden state h_t and the sampled
51+
token t+1, using a shared lm_head with the backbone.
52+
53+
Architecture:
54+
e = pre_fc_norm_embedding(embed_tokens(next_token_ids)) # (B, N, H)
55+
h = pre_fc_norm_hidden(hidden_states) # (B, N, H)
56+
fused = fc(concat([e, h], -1)) # (B, N, H)
57+
for layer in layers: fused = layer(fused, mask, cache)
58+
out = norm(fused)
59+
"""
60+
61+
def __init__(
62+
self,
63+
*,
64+
hidden_size: int,
65+
num_layers: int,
66+
rms_norm_eps: float,
67+
decoder_layer_factory: Callable[[], nn.Module],
68+
):
69+
super().__init__()
70+
self.pre_fc_norm_hidden = nn.RMSNorm(hidden_size, eps=rms_norm_eps)
71+
self.pre_fc_norm_embedding = nn.RMSNorm(hidden_size, eps=rms_norm_eps)
72+
self.fc = nn.Linear(hidden_size * 2, hidden_size, bias=False)
73+
self.layers = [decoder_layer_factory() for _ in range(num_layers)]
74+
self.norm = nn.RMSNorm(hidden_size, eps=rms_norm_eps)
75+
76+
def __call__(
77+
self,
78+
hidden_states: mx.array,
79+
next_token_ids: mx.array,
80+
embed_tokens: nn.Embedding,
81+
cache: Optional[Any] = None,
82+
) -> mx.array:
83+
embeds = embed_tokens(next_token_ids) # (B, N, H)
84+
e = self.pre_fc_norm_embedding(embeds)
85+
h = self.pre_fc_norm_hidden(hidden_states)
86+
fused = self.fc(mx.concatenate([e, h], axis=-1)) # (B, N, H)
87+
88+
if cache is None:
89+
cache = [None] * len(self.layers)
90+
91+
mask = create_attention_mask(fused, cache[0])
92+
for layer, c in zip(self.layers, cache):
93+
fused = layer(fused, mask, c)
94+
95+
return self.norm(fused) # (B, N, H)
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""Speculative-decoding adapter: SequentialMTPHead → mlx-lm PR #990 MTPModule API.
2+
3+
PR #990 introduces native MTP speculative decoding into mlx-lm with a
4+
specific module contract:
5+
6+
MTPModule(hidden_states, next_token_ids, embed_tokens, cache=None) -> fused_h
7+
# fused_h then fed into the shared lm_head to produce token-t+2 logits.
8+
9+
Our cppmega_v4 SequentialMTPHead exposes a *training-time* loss interface
10+
(returns per-depth logits + loss). This adapter wraps a SequentialMTPHead
11+
(or just its depth-0 block) and presents the (hidden, next_token_ids,
12+
embed_tokens) signature that PR #990's driver expects, so the same head
13+
can serve both training and speculative inference without a separate
14+
inference-only module.
15+
16+
Key behavioural mapping:
17+
- PR #990's pre_fc_norm_hidden / pre_fc_norm_embedding ↔ our
18+
SequentialMTPDepthBlock.hidden_norm / embedding_norm
19+
- PR #990's fc(concat([e, h])) ↔ our
20+
SequentialMTPDepthBlock.proj
21+
- PR #990's stack of MTPDecoderLayer ↔ our
22+
SequentialMTPDepthBlock.transformer + .output_norm
23+
24+
The adapter uses depth-0's block by default (depth-1+ are for deeper
25+
look-ahead in training; at inference time we predict one token ahead).
26+
"""
27+
28+
from typing import Any, Optional
29+
30+
import mlx.core as mx
31+
import mlx.nn as nn
32+
33+
from cppmega_v4.nn.mtp_v4 import SequentialMTPDepthBlock, SequentialMTPHead
34+
35+
36+
class SequentialMTPHeadAsMTPModule(nn.Module):
37+
"""Wraps a SequentialMTPDepthBlock to expose the PR #990 MTPModule API.
38+
39+
Construct directly from a SequentialMTPHead via ``from_head``, or build
40+
fresh from hidden_size if you only want the inference contract without
41+
sharing weights with a training head.
42+
"""
43+
44+
def __init__(self, depth_block: SequentialMTPDepthBlock):
45+
super().__init__()
46+
self.depth_block = depth_block
47+
48+
@classmethod
49+
def from_head(
50+
cls,
51+
head: SequentialMTPHead,
52+
depth_index: int = 0,
53+
) -> "SequentialMTPHeadAsMTPModule":
54+
if head.config.depth <= 0:
55+
raise ValueError(
56+
"Cannot build MTPModule adapter from a depth=0 SequentialMTPHead"
57+
)
58+
if not 0 <= depth_index < head.config.depth:
59+
raise ValueError(
60+
f"depth_index {depth_index} out of range [0, {head.config.depth})"
61+
)
62+
return cls(head.depth_blocks[depth_index])
63+
64+
@classmethod
65+
def fresh(cls, hidden_size: int) -> "SequentialMTPHeadAsMTPModule":
66+
return cls(SequentialMTPDepthBlock(hidden_size))
67+
68+
def __call__(
69+
self,
70+
hidden_states: mx.array,
71+
next_token_ids: mx.array,
72+
embed_tokens: nn.Embedding,
73+
cache: Optional[Any] = None, # accepted for PR #990 API compat; unused
74+
) -> mx.array:
75+
"""Run one MTP fusion + transformer step.
76+
77+
Args:
78+
hidden_states: (B, N, H) backbone pre-norm hidden state.
79+
next_token_ids: (B, N) sampled next-token ids (the speculative draft).
80+
embed_tokens: the backbone's token embedding (shared with lm_head
81+
or independent — caller decides).
82+
cache: KV cache slot list, ignored here (SequentialMTPDepthBlock's
83+
transformer is a single self-contained block).
84+
85+
Returns:
86+
(B, N, H) fused hidden state; caller applies the shared lm_head.
87+
"""
88+
del cache # PR #990 API compat; our block has no per-layer KV cache
89+
embeds = embed_tokens(next_token_ids)
90+
return self.depth_block(hidden_states, embeds)
91+
92+
93+
__all__ = ["SequentialMTPHeadAsMTPModule"]
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""Tests for SequentialMTPHead → mlx-lm PR #990 MTPModule adapter."""
2+
3+
import mlx.core as mx
4+
import mlx.nn as nn
5+
import numpy as np
6+
import pytest
7+
8+
from cppmega_v4.nn._external._mlx_lm_mtp_module_vendored import MTPModule
9+
from cppmega_v4.nn.mtp_speculative_adapter import SequentialMTPHeadAsMTPModule
10+
from cppmega_v4.nn.mtp_v4 import SequentialMTPDepthBlock, SequentialMTPHead
11+
12+
pytest.importorskip("mlx_lm", reason="mlx_lm not available")
13+
from cppmega_mlx.training.mtp import MTPLossConfig # noqa: E402
14+
15+
16+
def _make_emb(vocab=32, hidden=16) -> nn.Embedding:
17+
return nn.Embedding(vocab, hidden)
18+
19+
20+
def test_vendored_mtp_module_factory_shape_contract():
21+
"""Vendored MTPModule must produce (B, N, H) from (B, N, H) + (B, N)."""
22+
B, N, H = 1, 4, 16
23+
vocab = 32
24+
embed = _make_emb(vocab=vocab, hidden=H)
25+
26+
def decoder_layer():
27+
# Minimal pass-through layer — just RMSNorm. The MTPModule API only
28+
# requires layer(x, mask, cache) → x with same shape.
29+
norm = nn.RMSNorm(H)
30+
31+
class PassThrough(nn.Module):
32+
def __call__(self, x, mask=None, cache=None):
33+
return norm(x)
34+
return PassThrough()
35+
36+
mod = MTPModule(
37+
hidden_size=H, num_layers=1, rms_norm_eps=1e-5,
38+
decoder_layer_factory=decoder_layer,
39+
)
40+
hidden = mx.random.normal((B, N, H))
41+
next_ids = mx.array(np.random.randint(0, vocab, (B, N)).astype(np.int32))
42+
out = mod(hidden, next_ids, embed)
43+
assert out.shape == (B, N, H)
44+
45+
46+
def test_adapter_from_fresh_matches_shape_contract():
47+
"""Adapter built fresh must accept (hidden, next_ids, embed) and return (B,N,H)."""
48+
B, N, H = 1, 4, 16
49+
vocab = 32
50+
embed = _make_emb(vocab=vocab, hidden=H)
51+
mod = SequentialMTPHeadAsMTPModule.fresh(H)
52+
hidden = mx.random.normal((B, N, H))
53+
next_ids = mx.array(np.random.randint(0, vocab, (B, N)).astype(np.int32))
54+
out = mod(hidden, next_ids, embed)
55+
assert out.shape == (B, N, H)
56+
assert not bool(mx.any(mx.isnan(out)).item())
57+
58+
59+
def test_adapter_from_training_head_reuses_weights():
60+
"""Building from an existing SequentialMTPHead must share parameters with depth-0."""
61+
B, N, H = 1, 4, 16
62+
vocab = 32
63+
embed = _make_emb(vocab=vocab, hidden=H)
64+
head_lm = nn.Linear(H, vocab, bias=False)
65+
head = SequentialMTPHead(
66+
embed, head_lm, config=MTPLossConfig(depth=2),
67+
)
68+
adapter = SequentialMTPHeadAsMTPModule.from_head(head, depth_index=0)
69+
# adapter.depth_block should be the same object as head.depth_blocks[0]
70+
assert adapter.depth_block is head.depth_blocks[0]
71+
# And calling it should produce a valid fused hidden state.
72+
hidden = mx.random.normal((B, N, H))
73+
next_ids = mx.array(np.random.randint(0, vocab, (B, N)).astype(np.int32))
74+
out = adapter(hidden, next_ids, embed)
75+
assert out.shape == (B, N, H)
76+
77+
78+
def test_adapter_rejects_zero_depth_head():
79+
H = 16
80+
embed = _make_emb(hidden=H)
81+
head_lm = nn.Linear(H, 32, bias=False)
82+
head = SequentialMTPHead(embed, head_lm, config=MTPLossConfig(depth=0))
83+
with pytest.raises(ValueError, match="depth=0"):
84+
SequentialMTPHeadAsMTPModule.from_head(head)
85+
86+
87+
def test_adapter_rejects_out_of_range_depth_index():
88+
H = 16
89+
embed = _make_emb(hidden=H)
90+
head_lm = nn.Linear(H, 32, bias=False)
91+
head = SequentialMTPHead(embed, head_lm, config=MTPLossConfig(depth=2))
92+
with pytest.raises(ValueError, match="out of range"):
93+
SequentialMTPHeadAsMTPModule.from_head(head, depth_index=2)
94+
95+
96+
def test_adapter_call_is_deterministic():
97+
"""Same inputs → same outputs (no hidden RNG)."""
98+
B, N, H = 1, 3, 8
99+
vocab = 16
100+
embed = _make_emb(vocab=vocab, hidden=H)
101+
mod = SequentialMTPHeadAsMTPModule.fresh(H)
102+
hidden = mx.random.normal((B, N, H))
103+
next_ids = mx.array(np.random.randint(0, vocab, (B, N)).astype(np.int32))
104+
out_a = mod(hidden, next_ids, embed)
105+
out_b = mod(hidden, next_ids, embed)
106+
np.testing.assert_array_equal(np.array(out_a), np.array(out_b))

0 commit comments

Comments
 (0)