Skip to content

Commit 0120b7f

Browse files
committed
feat: extend HybridTinyLM with engram/concept blocks, MTP integration, attention modes, YAML
Composability extensions opening the door to drag-and-drop block reconfiguration: - Pattern symbols expanded from A/E/M/R to A/E/M/R/N/C - N: Engram causal n-gram branch (was training-only, now a first-class block) - C: Concept retrieval cross-attention (MLX port of nanochat/concepts.py CBlock) - D/G/| remain reserved upstream-only Megatron symbols (parity contract intact) - AttentionConfig.mode gains 'full' and 'gqa' aliases for the dense SDPA path, with strict num_kv_heads validation. HybridTinyConfig.attention_mode propagates the choice to all non-DSA A-layers. - HybridTinyConfig gains engram_*, concept_*, mtp_* fields and corresponding *_config() builders. MinimalMTPHead is auto-attached as model.mtp_head when mtp_enabled=True, sharing the model's token_embedding and lm_head. - HybridTinyConfig.to_yaml() / from_yaml() / from_dict() provide a serialization surface for run templates without changing the Python dataclass contract. 17 new tests in test_hybrid_lm_extensions.py cover symbol parsing, block construction, MTP attachment, YAML round-trip, and the new attention modes. Existing parity/counts assertions updated for the expanded role set. Closes cppmega-mlx-m26.
1 parent 2327b2b commit 0120b7f

8 files changed

Lines changed: 671 additions & 19 deletions

File tree

cppmega_mlx/models/hybrid_lm.py

Lines changed: 163 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
CausalSelfAttention,
2121
sparse_mla_fp8_route_enabled,
2222
)
23+
from cppmega_mlx.nn.concept import ConceptBlock, ConceptBlockConfig
24+
from cppmega_mlx.nn.engram import EngramBranch, EngramConfig
2325
from cppmega_mlx.nn.m2rnn import M2RNNConfig, M2RNNMixer
2426
from cppmega_mlx.nn.mamba3 import Mamba3Config, Mamba3ReferenceBlock
2527
from cppmega_mlx.nn.mhc import ManifoldBranchMixer, ManifoldBranchMixerConfig
@@ -28,17 +30,29 @@
2830
from cppmega_mlx.nn.structure_embedding import CppMegaStructureEmbedding
2931
from cppmega_mlx.recipes.pattern import ExpandedNamPattern, NamLayer, expand_nam_pattern
3032
from cppmega_mlx.runtime.kernel_policy import KernelPath, selected_path
31-
32-
HybridBackend = Literal["attention", "mamba3", "moe", "m2rnn"]
33-
HybridBlockModule = CausalSelfAttention | Mamba3ReferenceBlock | ReferenceMoE | M2RNNMixer
33+
from cppmega_mlx.training.mtp import MinimalMTPHead, MTPLossConfig
34+
35+
HybridBackend = Literal["attention", "mamba3", "moe", "m2rnn", "engram", "concept"]
36+
HybridBlockModule = (
37+
CausalSelfAttention
38+
| Mamba3ReferenceBlock
39+
| ReferenceMoE
40+
| M2RNNMixer
41+
| EngramBranch
42+
| ConceptBlock
43+
)
3444

3545
_ROUTE_SYMBOL_BACKENDS: dict[str, HybridBackend] = {
3646
"A": "attention",
3747
"M": "mamba3",
3848
"E": "moe",
3949
"R": "m2rnn",
50+
"N": "engram",
51+
"C": "concept",
4052
}
4153

54+
HybridAttentionMode = Literal["mla", "dsa", "full", "gqa"]
55+
4256

4357
class StructureEmbeddingConfigKwargs(TypedDict):
4458
hidden_size: int
@@ -110,6 +124,28 @@ class HybridTinyConfig:
110124
ngram_hash_seed: int | None = None
111125
mhc_enabled: bool = False
112126
grad_checkpoint: bool = False
127+
# Attention mode default applied to A-layers that did not get DSA routing.
128+
# "mla" preserves the legacy dense path. "full" and "gqa" are explicit
129+
# aliases for the same SDPA path with stricter num_kv_heads validation.
130+
attention_mode: HybridAttentionMode = "mla"
131+
# Engram (symbol "N") — local causal n-gram branch from cppmega_mlx.nn.engram.
132+
engram_ngram_orders: tuple[int, ...] = (2, 3, 4)
133+
engram_bottleneck_dim: int = 0
134+
engram_dropout: float = 0.0
135+
engram_gated: bool = False
136+
engram_conv_kernel: int = 0
137+
# Concept (symbol "C") — concept-retrieval cross-attention ported from
138+
# nanochat. ``concept_dim=None`` means use hidden_size.
139+
concept_num_concepts: int = 64
140+
concept_num_heads: int = 4
141+
concept_dim: int | None = None
142+
# MTP — Multi-Token Prediction. When enabled, an MTPHead is attached to the
143+
# model in HybridTinyLM.__init__ and made reachable as ``model.mtp_head``.
144+
mtp_enabled: bool = False
145+
mtp_depth: int = 2
146+
mtp_decay: float = 0.6
147+
mtp_loss_weight: float = 0.3
148+
mtp_ignore_index: int = -1
113149

114150
def __post_init__(self) -> None:
115151
if self.vocab_size < 2:
@@ -141,13 +177,26 @@ def __post_init__(self) -> None:
141177
# Validate the route plan at config construction time, including DSA
142178
# ranks at tiny depths.
143179
self.expanded_pattern()
144-
self.attention_config("mla")
180+
# Validate the user-selected dense attention mode end-to-end.
181+
if self.attention_mode not in ("mla", "dsa", "full", "gqa"):
182+
raise ValueError(
183+
f"attention_mode must be one of 'mla', 'dsa', 'full', 'gqa', "
184+
f"got {self.attention_mode!r}"
185+
)
186+
self.attention_config(self.attention_mode)
187+
# Always also validate the dsa mode contract because dsa_a_layer_ranks
188+
# may pin specific A-layers to dsa regardless of attention_mode.
189+
if self.attention_mode != "dsa":
190+
self.attention_config("dsa")
145191
self.mamba3_config()
146192
self.m2rnn_config()
147193
self.moe_config()
148194
self.structure_embedding_config()
149195
self.ngram_hash_config()
150196
self.mhc_config()
197+
self.engram_config()
198+
self.concept_config()
199+
self.mtp_config()
151200

152201
def expanded_pattern(self) -> ExpandedNamPattern:
153202
return expand_nam_pattern(
@@ -156,12 +205,15 @@ def expanded_pattern(self) -> ExpandedNamPattern:
156205
dsa_a_layer_ranks=self.dsa_a_layer_ranks,
157206
)
158207

159-
def attention_config(self, mode: Literal["mla", "dsa"]) -> AttentionConfig:
208+
def attention_config(
209+
self, mode: HybridAttentionMode | None = None
210+
) -> AttentionConfig:
211+
active_mode: HybridAttentionMode = mode if mode is not None else self.attention_mode
160212
return AttentionConfig(
161213
d_model=self.hidden_size,
162214
num_q_heads=self.num_attention_heads,
163215
num_kv_heads=self.num_attention_kv_heads,
164-
mode=mode,
216+
mode=active_mode,
165217
sparse_topk=self.attention_sparse_topk,
166218
)
167219

@@ -263,9 +315,80 @@ def mhc_config(self) -> ManifoldBranchMixerConfig | None:
263315
return None
264316
return ManifoldBranchMixerConfig(hidden_size=self.hidden_size, max_branches=2)
265317

318+
def engram_config(self) -> EngramConfig:
319+
return EngramConfig(
320+
hidden_size=self.hidden_size,
321+
ngram_orders=self.engram_ngram_orders,
322+
bottleneck_dim=self.engram_bottleneck_dim,
323+
dropout=self.engram_dropout,
324+
gated=self.engram_gated,
325+
conv_kernel=self.engram_conv_kernel,
326+
)
327+
328+
def concept_config(self) -> ConceptBlockConfig:
329+
return ConceptBlockConfig(
330+
hidden_size=self.hidden_size,
331+
num_concepts=self.concept_num_concepts,
332+
num_heads=self.concept_num_heads,
333+
concept_dim=self.concept_dim,
334+
)
335+
336+
def mtp_config(self) -> MTPLossConfig | None:
337+
if not self.mtp_enabled:
338+
return None
339+
return MTPLossConfig(
340+
depth=self.mtp_depth,
341+
decay=self.mtp_decay,
342+
loss_weight=self.mtp_loss_weight,
343+
ignore_index=self.mtp_ignore_index,
344+
)
345+
266346
def to_dict(self) -> dict[str, object]:
267347
return asdict(self)
268348

349+
def to_yaml(self) -> str:
350+
"""Serialize this config to a YAML document.
351+
352+
Lists are emitted as flow-style for compactness. Use ``from_yaml`` to
353+
parse the output back into a ``HybridTinyConfig``.
354+
"""
355+
import yaml # PyYAML is part of the existing dev requirements.
356+
357+
return yaml.safe_dump(
358+
self.to_dict(),
359+
sort_keys=False,
360+
default_flow_style=None,
361+
)
362+
363+
@classmethod
364+
def from_dict(cls, data: dict[str, object]) -> HybridTinyConfig:
365+
"""Build a config from a plain dict (e.g., loaded YAML/JSON)."""
366+
367+
coerced = dict(data)
368+
# YAML and JSON lose tuple-ness; coerce sequence-typed fields back.
369+
for field_name in (
370+
"dsa_a_layer_ranks",
371+
"ngram_hash_orders",
372+
"engram_ngram_orders",
373+
):
374+
if field_name in coerced and coerced[field_name] is not None:
375+
coerced[field_name] = tuple(coerced[field_name]) # type: ignore[arg-type]
376+
return cls(**coerced) # type: ignore[arg-type]
377+
378+
@classmethod
379+
def from_yaml(cls, text: str) -> HybridTinyConfig:
380+
"""Parse a YAML document into a ``HybridTinyConfig`` with validation."""
381+
382+
import yaml
383+
384+
loaded = yaml.safe_load(text)
385+
if not isinstance(loaded, dict):
386+
raise ValueError(
387+
"from_yaml expects a YAML mapping at the top level, "
388+
f"got {type(loaded).__name__}"
389+
)
390+
return cls.from_dict(loaded)
391+
269392

270393
class HybridTinyBlock(nn.Module):
271394
"""One pre-norm residual A/M/E/R route block.
@@ -290,7 +413,12 @@ def __init__(self, layer: NamLayer, config: HybridTinyConfig):
290413
self.block: HybridBlockModule
291414

292415
if layer.symbol == "A":
293-
mode = layer.attention_route or "mla"
416+
# DSA pinning via dsa_a_layer_ranks always wins. Otherwise the
417+
# model-wide attention_mode (default 'mla', or user-chosen
418+
# 'full'/'gqa') applies to this A-layer.
419+
mode: HybridAttentionMode = (
420+
"dsa" if layer.attention_route == "dsa" else config.attention_mode
421+
)
294422
self.backend: HybridBackend = "attention"
295423
self.block = CausalSelfAttention(config.attention_config(mode))
296424
elif layer.symbol == "M":
@@ -302,6 +430,12 @@ def __init__(self, layer: NamLayer, config: HybridTinyConfig):
302430
elif layer.symbol == "R":
303431
self.backend = "m2rnn"
304432
self.block = M2RNNMixer(config.m2rnn_config())
433+
elif layer.symbol == "N":
434+
self.backend = "engram"
435+
self.block = EngramBranch(config.engram_config())
436+
elif layer.symbol == "C":
437+
self.backend = "concept"
438+
self.block = ConceptBlock(config.concept_config())
305439
else: # pragma: no cover - expand_nam_pattern rejects this first.
306440
raise ValueError(f"unsupported hybrid layer symbol {layer.symbol!r}")
307441

@@ -321,6 +455,14 @@ def moe_block(self) -> ReferenceMoE | None:
321455
def m2rnn_block(self) -> M2RNNMixer | None:
322456
return self.block if self.backend == "m2rnn" else None # type: ignore[return-value]
323457

458+
@property
459+
def engram_block(self) -> EngramBranch | None:
460+
return self.block if self.backend == "engram" else None # type: ignore[return-value]
461+
462+
@property
463+
def concept_block(self) -> ConceptBlock | None:
464+
return self.block if self.backend == "concept" else None # type: ignore[return-value]
465+
324466
def __call__(
325467
self,
326468
hidden_states: mx.array,
@@ -362,6 +504,8 @@ def validate_backend(self) -> None:
362504
"mamba3": Mamba3ReferenceBlock,
363505
"moe": ReferenceMoE,
364506
"m2rnn": M2RNNMixer,
507+
"engram": EngramBranch,
508+
"concept": ConceptBlock,
365509
}[self.backend]
366510
if not isinstance(self.block, expected_cls):
367511
raise ValueError(
@@ -403,6 +547,10 @@ def route_delta(
403547
)
404548
else:
405549
delta, _ = m2rnn(x)
550+
elif self.backend == "engram":
551+
delta = cast(EngramBranch, self.block)(x)
552+
elif self.backend == "concept":
553+
delta = cast(ConceptBlock, self.block)(x)
406554
else: # pragma: no cover - self.backend is fixed during construction.
407555
raise ValueError(f"unsupported hybrid backend {self.backend!r}")
408556
return delta
@@ -440,6 +588,13 @@ def __init__(
440588
self.norm = nn.RMSNorm(cfg.hidden_size)
441589
self.lm_head = nn.Linear(cfg.hidden_size, cfg.vocab_size, bias=False)
442590

591+
mtp_config = cfg.mtp_config()
592+
self.mtp_head: MinimalMTPHead | None = (
593+
MinimalMTPHead(self.token_embedding, self.lm_head, config=mtp_config)
594+
if mtp_config is not None
595+
else None
596+
)
597+
443598
if dtype is not None and dtype != mx.float32:
444599
self.set_dtype(dtype)
445600

@@ -640,6 +795,7 @@ def _attention_layer_count(layers: list[HybridTinyBlock]) -> int:
640795
return sum(1 for layer in layers if layer.backend == "attention")
641796

642797
__all__ = [
798+
"HybridAttentionMode",
643799
"HybridBackend",
644800
"HybridBlockModule",
645801
"HybridTinyBlock",

cppmega_mlx/nn/attention.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
from cppmega_mlx.inference.engine import ContiguousKVCache
1515
from cppmega_mlx.runtime.kernel_policy import KernelPath, record_dispatch, selected_path
1616

17-
AttentionMode = Literal["mla", "dsa"]
17+
AttentionMode = Literal["mla", "dsa", "full", "gqa"]
18+
_DENSE_MODES: frozenset[str] = frozenset({"mla", "full", "gqa"})
1819
RopeType = Literal["standard", "llama3", "yarn"]
1920
SPARSE_MLA_FP8_PRODUCER_OWNER = (
2021
"cppmega_mlx.nn.attention.CausalSelfAttention.prepare_sparse_mla_fp8"
@@ -68,8 +69,21 @@ class AttentionConfig:
6869
sparse_topk: int = 16
6970

7071
def __post_init__(self) -> None:
71-
if self.mode not in ("mla", "dsa"):
72-
raise ValueError(f"mode must be 'mla' or 'dsa', got {self.mode!r}")
72+
if self.mode not in ("mla", "dsa", "full", "gqa"):
73+
raise ValueError(
74+
f"mode must be one of 'mla', 'dsa', 'full', 'gqa', got {self.mode!r}"
75+
)
76+
if self.mode == "gqa":
77+
if self.num_kv_heads is None or self.num_kv_heads == self.num_q_heads:
78+
raise ValueError(
79+
"mode='gqa' requires num_kv_heads to be set to a value "
80+
"strictly less than num_q_heads"
81+
)
82+
if self.mode == "full":
83+
if self.num_kv_heads is not None and self.num_kv_heads != self.num_q_heads:
84+
raise ValueError(
85+
"mode='full' requires num_kv_heads to equal num_q_heads or be None"
86+
)
7387
if self.d_model <= 0:
7488
raise ValueError(f"d_model must be positive, got {self.d_model}")
7589
if self.num_q_heads <= 0:

0 commit comments

Comments
 (0)