Skip to content

Commit da6f862

Browse files
committed
feat(v4/buildspec): MBSpec Stage C — Rewriter protocol + MTPRewriter
Adds the first concrete graph rewriter (ModelBuildSpec.md §3.4, §6 C). cppmega_v4/buildspec/rewriters.py: - MTPRewriter(k, beta=None, share_backbone=True, add_head_param_group=True) Multi-Token-Prediction rewrite. K=1 is no-op fast path; K≥2: * locates the head brick (explicit params['is_head']=True wins; falls back to last mlp/attention node in graph order) * renames it to <head>_0 and clones K-1 more (<head>_1 .. <head>_{k-1}) with weight modules cleared (build-time re-init) * rewires every edge into the old head onto every new head_i so each clone receives the same backbone activations * rewrites loss CE → mtp_weighted with the original head name as prefix, honouring optional custom beta tuple * optionally prepends a regex:.*<head>_\d+$ ParamGroup to the optimizer, inheriting betas (AdamW) / ns_steps (Muon) from the first existing group Protocol-conformant: name + required_preconditions={"single_head"} + provided_postconditions={"mtp_k_heads"} (empty for K=1 no-op). - HeadDetectionError when no head candidate exists. - LossRewriteError when input loss isn't CE (auto-conversion unsafe). cppmega_v4/buildspec/model_build_spec.py: - Rewriter Protocol marked @runtime_checkable so isinstance(r, Rewriter) works for tests / GUI lookups. Tests (tests/v4/test_mbspec_stage_c.py, 25 cases): - protocol conformance (isinstance check); name/precondition/post advertised correctly; K=1 advertises no post; frozen dataclass - construction rejection: k<1; mismatched beta length - K=1 fast-path returns input - K=2 / K=3 graph rewrite: head node count, edge wiring (producer → every clone), backbone untouched - loss rewrite: CE → mtp_weighted with k/beta/head_outputs; custom head-name prefix; non-CE input raises LossRewriteError - optim rewrite: head-only group prepended with regex matcher; inheritance of betas (AdamW) / ns_steps (Muon) / neither (SGD); opt-out via add_head_param_group=False - head detection: explicit is_head marker beats kind heuristic; no candidate raises HeadDetectionError - composition: apply_rewrites adds mtp_k_heads state token; verify_build_spec clean on Qwen3-Next with MTPRewriter - system: K=2 doubles head count only; share_backbone=False keeps backbone intact (Stage C scope — backbone replication is future work) Full v4 regression: 858 passed / 5 skipped / 0 failed.
1 parent 6314c9f commit da6f862

4 files changed

Lines changed: 623 additions & 1 deletion

File tree

cppmega_v4/buildspec/__init__.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@
3535
RewriteOrderError,
3636
Rewriter,
3737
)
38+
from cppmega_v4.buildspec.rewriters import (
39+
HeadDetectionError,
40+
LossRewriteError,
41+
MTPRewriter,
42+
)
3843
from cppmega_v4.buildspec.optim_spec import (
3944
OPTIM_BUILTINS,
4045
OptimKind,
@@ -50,7 +55,10 @@
5055
"BuildDiagnostic",
5156
"BuildDiagnosticSeverity",
5257
"BuildDiagnostics",
58+
"HeadDetectionError",
5359
"LOSS_BUILTINS",
60+
"LossRewriteError",
61+
"MTPRewriter",
5462
"LossKind",
5563
"LossSpec",
5664
"ModelBuildSpec",

cppmega_v4/buildspec/model_build_spec.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
from collections.abc import Mapping
1717
from dataclasses import dataclass, field
18-
from typing import TYPE_CHECKING, Protocol
18+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
1919

2020
from cppmega_v4.buildspec.loss_spec import LossSpec
2121
from cppmega_v4.buildspec.optim_spec import OptimSpec
@@ -25,6 +25,7 @@
2525
pass
2626

2727

28+
@runtime_checkable
2829
class Rewriter(Protocol):
2930
"""A pure :class:`ModelBuildSpec` transformation.
3031

cppmega_v4/buildspec/rewriters.py

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
"""Concrete graph rewriters.
2+
3+
Stage C scope (this commit): :class:`MTPRewriter`.
4+
5+
A rewriter is a callable ``(ModelBuildSpec) -> ModelBuildSpec`` that
6+
matches the :class:`cppmega_v4.buildspec.Rewriter` Protocol. It:
7+
8+
- reads the input graph + loss + optim
9+
- materialises a new graph (adding/removing nodes / edges)
10+
- rewrites the loss spec to match the new outputs (e.g. CE → MTP-weighted)
11+
- optionally adds a new optimizer parameter group (e.g. head-only lr)
12+
13+
Rewriters are PURE — they never mutate the input spec; they return a
14+
fresh one via :meth:`ModelBuildSpec.replace`.
15+
16+
MTPRewriter
17+
-----------
18+
19+
Multi-Token Prediction rewrite. Given a spec with a single head brick
20+
(``state_token "single_head"``), materialises K-1 additional head copies
21+
and rewrites the loss to :func:`mtp_weighted_loss`. The new state token
22+
``"mtp_k_heads"`` advertises the post-condition for later rewriters.
23+
24+
Naming convention: the original head brick is renamed
25+
``<head>_0`` and copies become ``<head>_1 ... <head>_{k-1}``. Loss
26+
head_outputs match this scheme (``logits_0 / logits_1 / ...``).
27+
28+
Param-group integration: when ``add_head_param_group=True`` (default
29+
True), the optimizer gains a new high-priority ``regex:.*_head_\\d+$``
30+
group with the same lr as the base — callers can override the head-only
31+
lr after the fact via :meth:`OptimSpec.replace`.
32+
"""
33+
34+
from __future__ import annotations
35+
36+
from dataclasses import dataclass, field
37+
from typing import Final
38+
39+
from cppmega_v4.buildspec.loss_spec import (
40+
LossKind,
41+
LossSpec,
42+
mtp_weighted_loss,
43+
)
44+
from cppmega_v4.buildspec.model_build_spec import (
45+
ModelBuildSpec,
46+
Rewriter,
47+
)
48+
from cppmega_v4.buildspec.optim_spec import (
49+
OptimKind,
50+
OptimSpec,
51+
ParamGroup,
52+
)
53+
from cppmega_v4.fusion.brick_graph import BrickGraph, BrickNode
54+
55+
56+
# ---------------------------------------------------------------------------
57+
# Helpers
58+
# ---------------------------------------------------------------------------
59+
60+
61+
_HEAD_KINDS: Final[frozenset[str]] = frozenset({
62+
# bricks that produce the "logits" head — Stage C scope. Add more
63+
# as we wire heads explicitly. For now we recognise mlp-as-head and
64+
# accept an explicit ``is_head=True`` attribute on a BrickNode.
65+
"mlp",
66+
"attention",
67+
})
68+
69+
70+
def _find_head_node(graph: BrickGraph) -> BrickNode:
71+
"""Locate the head brick.
72+
73+
Resolution order:
74+
1. Last node in the graph whose ``params`` contains ``"is_head": True``.
75+
2. Last node in the graph whose kind is in :data:`_HEAD_KINDS`.
76+
3. Else: raises :class:`HeadDetectionError`.
77+
"""
78+
explicit = [
79+
n for n in graph.nodes if n.params.get("is_head") is True
80+
]
81+
if explicit:
82+
return explicit[-1]
83+
typed = [n for n in graph.nodes if n.kind in _HEAD_KINDS]
84+
if not typed:
85+
raise HeadDetectionError(
86+
"no head brick found — annotate a brick with "
87+
"`params={'is_head': True}` or end the graph with one of "
88+
f"{sorted(_HEAD_KINDS)}"
89+
)
90+
return typed[-1]
91+
92+
93+
class HeadDetectionError(RuntimeError):
94+
"""Raised by :class:`MTPRewriter` when no head brick can be located."""
95+
96+
97+
# ---------------------------------------------------------------------------
98+
# MTPRewriter
99+
# ---------------------------------------------------------------------------
100+
101+
102+
@dataclass(frozen=True)
103+
class MTPRewriter:
104+
"""K-head Multi-Token-Prediction rewriter.
105+
106+
Fields:
107+
k: number of prediction heads (≥ 1). K=1 is a no-op (returns input
108+
spec unchanged besides the postcondition token).
109+
beta: optional per-head weights for the mtp_weighted loss. When
110+
None, falls back to the default geometric decay used by
111+
:func:`mtp_weighted_loss`.
112+
share_backbone: when True (default), the backbone bricks are kept
113+
as-is and only the head is replicated K times. When False, the
114+
original head node is renamed ``_0`` and new ``_1.._{k-1}``
115+
clones are added (still pointing into the backbone end-node).
116+
add_head_param_group: when True (default), the optimizer spec gets
117+
a new head-only param group with the same lr as the first
118+
existing group. Useful when the GUI wants to surface a separate
119+
slider for head lr.
120+
"""
121+
122+
k: int = 2
123+
beta: tuple[float, ...] | None = None
124+
share_backbone: bool = True
125+
add_head_param_group: bool = True
126+
127+
# Rewriter protocol
128+
name: str = field(init=False)
129+
required_preconditions: frozenset[str] = field(init=False)
130+
provided_postconditions: frozenset[str] = field(init=False)
131+
132+
def __post_init__(self) -> None:
133+
if self.k < 1:
134+
raise ValueError(f"MTPRewriter.k must be ≥ 1, got {self.k}")
135+
if self.beta is not None and len(self.beta) != self.k:
136+
raise ValueError(
137+
f"MTPRewriter.beta length ({len(self.beta)}) must equal "
138+
f"k ({self.k})"
139+
)
140+
# Init the Rewriter-protocol fields.
141+
object.__setattr__(self, "name", f"MTPRewriter(k={self.k})")
142+
object.__setattr__(
143+
self, "required_preconditions", frozenset({"single_head"}),
144+
)
145+
object.__setattr__(
146+
self, "provided_postconditions",
147+
frozenset() if self.k == 1 else frozenset({"mtp_k_heads"}),
148+
)
149+
150+
def __call__(self, spec: ModelBuildSpec) -> ModelBuildSpec:
151+
if self.k == 1:
152+
# No-op fast path. Don't touch the loss either — caller asked
153+
# for K=1 explicitly.
154+
return spec
155+
156+
if spec.loss.kind is not LossKind.CROSS_ENTROPY:
157+
raise LossRewriteError(
158+
f"MTPRewriter requires LossKind.CROSS_ENTROPY input "
159+
f"(got {spec.loss.kind!r}) — automatic rewrite to "
160+
"mtp_weighted only safe from a pure CE base"
161+
)
162+
163+
head = _find_head_node(spec.graph)
164+
original_loss_name = spec.loss.head_outputs[0]
165+
166+
# ---- rewrite graph: rename head -> head_0; add head_1..k-1 ----
167+
new_nodes: list[BrickNode] = []
168+
existing_names = {n.name for n in spec.graph.nodes}
169+
existing_names.discard(head.name)
170+
171+
renamed_head = BrickNode(
172+
kind=head.kind,
173+
name=f"{head.name}_0",
174+
params=dict(head.params),
175+
module=head.module,
176+
)
177+
head_names: list[str] = [renamed_head.name]
178+
for n in spec.graph.nodes:
179+
if n.name == head.name:
180+
new_nodes.append(renamed_head)
181+
else:
182+
new_nodes.append(n)
183+
184+
for i in range(1, self.k):
185+
clone_name = f"{head.name}_{i}"
186+
# Ensure unique
187+
if clone_name in existing_names:
188+
clone_name = f"{head.name}_mtp_{i}"
189+
existing_names.add(clone_name)
190+
new_nodes.append(
191+
BrickNode(
192+
kind=head.kind,
193+
name=clone_name,
194+
params=dict(head.params),
195+
module=None, # weights re-init at build time
196+
)
197+
)
198+
head_names.append(clone_name)
199+
200+
# ---- rewrite edges: every edge into old head_name -> head_0 ----
201+
producer_of_head = [
202+
p for p, c in spec.graph.edges if c == head.name
203+
]
204+
new_edges: list[tuple[str, str]] = []
205+
for p, c in spec.graph.edges:
206+
if c == head.name:
207+
new_edges.append((p, renamed_head.name))
208+
# Also wire the producer into every new head clone.
209+
for clone in head_names[1:]:
210+
new_edges.append((p, clone))
211+
elif p == head.name:
212+
# Anything that consumed the old head now consumes head_0
213+
# (the "real" prediction); MTP heads are leaves that
214+
# only feed the loss, not subsequent bricks.
215+
new_edges.append((renamed_head.name, c))
216+
else:
217+
new_edges.append((p, c))
218+
219+
# If the head had no producers (size-1 graph), wire the clones
220+
# directly into the head_0 slot — they'll consume the same input
221+
# the head_0 brick gets at runtime.
222+
if not producer_of_head and self.k > 1:
223+
for clone in head_names[1:]:
224+
new_edges.append((renamed_head.name, clone))
225+
226+
new_graph = BrickGraph(nodes=tuple(new_nodes), edges=tuple(new_edges))
227+
228+
# ---- rewrite loss: CE -> mtp_weighted -------------------------
229+
new_loss = mtp_weighted_loss(
230+
k=self.k,
231+
beta=self.beta,
232+
head_output_prefix=original_loss_name,
233+
)
234+
235+
# ---- rewrite optim: optionally add head-only group ------------
236+
new_optim = spec.optim
237+
if self.add_head_param_group:
238+
base_lr = spec.optim.groups[0].lr
239+
head_matcher = f"regex:.*{original_loss_name}_\\d+$"
240+
head_group = ParamGroup(
241+
matcher=head_matcher,
242+
lr=base_lr,
243+
weight_decay=spec.optim.groups[0].weight_decay,
244+
betas=(
245+
spec.optim.groups[0].betas
246+
if spec.optim.kind in {OptimKind.ADAMW, OptimKind.MUON_ADAMW_HYBRID}
247+
else None
248+
),
249+
ns_steps=(
250+
spec.optim.groups[0].ns_steps
251+
if spec.optim.kind is OptimKind.MUON
252+
else None
253+
),
254+
)
255+
# Prepend so the head matcher wins over the catch-all "all".
256+
new_optim = OptimSpec(
257+
kind=spec.optim.kind,
258+
groups=(head_group, *spec.optim.groups),
259+
gradient_clip_norm=spec.optim.gradient_clip_norm,
260+
mixed_precision=spec.optim.mixed_precision,
261+
)
262+
263+
return spec.replace(
264+
graph=new_graph, loss=new_loss, optim=new_optim,
265+
)
266+
267+
268+
class LossRewriteError(RuntimeError):
269+
"""Raised by MTPRewriter when the input loss can't be safely converted."""
270+
271+
272+
__all__ = [
273+
"HeadDetectionError",
274+
"LossRewriteError",
275+
"MTPRewriter",
276+
]

0 commit comments

Comments
 (0)