Skip to content

Commit 73f8354

Browse files
committed
feat(v4/fusion): Stage B — descriptor synthesizer for V4 bricks
Adds cppmega_v4.fusion.descriptor_synthesizer covering every kind in BLOCK_BUILDERS with a structurally valid PathCBrickScheduleDescriptor: - synthesize_descriptor_for_brick(kind) builds a fallback descriptor whose schedule_family / required_codegen_steps / supports_backward are derived from the brick's FusionEligibility category. Opaque bricks (SDPA, cross-attn, sparse, recurrence) are tagged implementation_status="opaque_brick_passthrough" so downstream consumers group them into regions via DLPack handoff instead of inlining them into a fused PrimFunc. - build_v4_extended_registry() returns a registry that combines the legacy v3 descriptors with v4 fallbacks; legacy entries always win on collision (descriptor_for never returns the synth when a hand-written one exists). This unblocks descriptors_for_signature() for any chain assembled from BLOCK_BUILDERS so Stage C's auto-planner can call Path C codegen without None lookups. Tests (tests/v4/test_fusion_stage_b.py, 28 cases): - per-brick synth: known kinds (gdn linear_attn, gated_attention opaque sdpa), unknown kind safe passthrough - parametrized: every BLOCK_BUILDERS kind produces a registry-acceptable descriptor - registry: legacy preserved, no clobber, full kind coverage, AOT <op>_bwd synth fires only for supports_backward=True - system: signature lookup for Qwen3-Next (3xGDN + gated + moe) and Ling 2.6 (7x bailing_linear + mla + moe) chains Full v4 regression: 450 passed / 5 skipped / 0 failed.
1 parent 55066d8 commit 73f8354

3 files changed

Lines changed: 302 additions & 0 deletions

File tree

cppmega_v4/fusion/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@
2727
FusionEligibility,
2828
can_fuse_pair,
2929
)
30+
from cppmega_v4.fusion.descriptor_synthesizer import (
31+
build_v4_extended_registry,
32+
synthesize_descriptor_for_brick,
33+
)
3034
from cppmega_v4.fusion.dlpack_bridge import (
3135
dlpack_available,
3236
host_copy_fallback,
@@ -38,11 +42,13 @@
3842
"BrickGraph",
3943
"BrickNode",
4044
"FusionEligibility",
45+
"build_v4_extended_registry",
4146
"can_fuse_pair",
4247
"dlpack_available",
4348
"from_block_specs",
4449
"from_mlx_model",
4550
"host_copy_fallback",
4651
"mlx_to_tilelang",
52+
"synthesize_descriptor_for_brick",
4753
"tilelang_to_mlx",
4854
]
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
"""Stage B — auto-synthesize Path C brick descriptors for V4 bricks.
2+
3+
Background:
4+
``cppmega_mlx.runtime.path_c_fusion_schedules.default_path_c_brick_schedule_descriptor_registry``
5+
ships 5 hand-written descriptors (legacy v3 train block). The 15+ V4
6+
bricks in ``cppmega_v4.models.unified_superblock_v4.BLOCK_BUILDERS``
7+
have no descriptors, so any region built from them fails the
8+
``descriptors_for_signature`` lookup and Path C codegen bails.
9+
10+
Strategy:
11+
- For each V4 brick, synthesise a "fallback descriptor" that marks the
12+
op as an opaque kernel (already implemented in Path B / mlx-lm SDPA /
13+
upstream) but is structurally valid for the registry: non-empty
14+
op_name, well-formed required_codegen_steps, schedule_family chosen
15+
by the FusionEligibility category (linear_attn / sdpa_attention /
16+
moe / ssm / nonlinear_rnn / cross_attn / sparse_attn / norm_or_proj).
17+
- The fallback descriptor's ``implementation_status`` is
18+
``"opaque_brick_passthrough"`` — that signals to downstream pattern
19+
matchers that the brick must NOT be inlined into a fused PrimFunc,
20+
but it CAN be grouped into a region (and the planner can lower
21+
surrounding bricks fused with DLPack hand-off on the brick's edges).
22+
23+
Public surface:
24+
- ``synthesize_descriptor_for_brick(kind) -> PathCBrickScheduleDescriptor``
25+
- ``build_v4_extended_registry() -> PathCBrickScheduleDescriptorRegistry``
26+
Combines the default v3 registry with v4 fallback descriptors. All
27+
BLOCK_BUILDERS kinds get a descriptor; existing v3 entries are kept
28+
as-is (no clobber).
29+
"""
30+
31+
from __future__ import annotations
32+
33+
from cppmega_mlx.runtime.path_c_fusion_schedules import (
34+
PathCBrickScheduleDescriptor,
35+
PathCBrickScheduleDescriptorRegistry,
36+
default_path_c_brick_schedule_descriptor_registry,
37+
)
38+
from cppmega_v4.fusion.compatibility import _CATEGORY_BY_KIND
39+
from cppmega_v4.models.unified_superblock_v4 import BLOCK_BUILDERS
40+
41+
42+
# Map fusion category -> schedule_family token (descriptor metadata).
43+
# Categories that fuse via path_c get a family hint matching the kind of
44+
# loop they're built around; opaque ones get a sentinel that downstream
45+
# pattern matchers treat as "do not inline".
46+
_FAMILY_BY_CATEGORY: dict[str, str] = {
47+
"linear_attn": "linear_attn_scan",
48+
"ssm": "ssm_chunkwise_scan",
49+
"sdpa_attention": "opaque_sdpa_with_outputs",
50+
"cross_attn": "opaque_cross_attn",
51+
"nonlinear_rnn": "opaque_recurrence",
52+
"moe": "moe_route_combine",
53+
"sparse_attn": "opaque_sparse",
54+
"norm_or_proj": "pointwise_or_linear",
55+
"unknown": "opaque_passthrough",
56+
}
57+
58+
59+
def _required_codegen_steps_for(kind: str, category: str) -> tuple[str, ...]:
60+
"""Per-brick codegen step sequence. Used by descriptor consumers to
61+
decide which sub-templates to thread together. Conservative defaults
62+
so an unknown brick still passes the registry's structural check."""
63+
if category == "linear_attn":
64+
return (
65+
f"{kind}_scan_descriptor",
66+
f"{kind}_state_init",
67+
)
68+
if category == "ssm":
69+
return (
70+
f"{kind}_ssm_descriptor",
71+
f"{kind}_chunkwise_internal_buffers",
72+
)
73+
if category == "sdpa_attention":
74+
return (
75+
f"{kind}_opaque_sdpa_descriptor",
76+
f"{kind}_output_gate_o_proj_fragment",
77+
)
78+
if category == "cross_attn":
79+
return (
80+
f"{kind}_opaque_cross_attn_descriptor",
81+
f"{kind}_residual_output_fragment",
82+
)
83+
if category == "nonlinear_rnn":
84+
return (
85+
f"{kind}_opaque_recurrence_descriptor",
86+
f"{kind}_pre_norm_fragment",
87+
)
88+
if category == "moe":
89+
return (
90+
f"{kind}_route_descriptor",
91+
f"{kind}_expert_ffn_fragment",
92+
f"{kind}_combine_descriptor",
93+
)
94+
if category == "sparse_attn":
95+
return (
96+
f"{kind}_opaque_sparse_descriptor",
97+
)
98+
if category == "norm_or_proj":
99+
return (
100+
f"{kind}_pointwise_descriptor",
101+
)
102+
return (f"{kind}_opaque_passthrough",)
103+
104+
105+
def _supports_backward(category: str) -> bool:
106+
# Conservative: only the categories with established gradient paths
107+
# in our codebase claim backward support. SDPA-backed and sparse
108+
# bricks have backwards via mlx-lm/MLX autodiff but not via Path C
109+
# AOT — the descriptor itself doesn't know how to emit a backward
110+
# PrimFunc fragment, so it sets supports_backward=False.
111+
return category in {"linear_attn", "ssm", "norm_or_proj", "nonlinear_rnn"}
112+
113+
114+
def synthesize_descriptor_for_brick(kind: str) -> PathCBrickScheduleDescriptor:
115+
"""Build a fallback descriptor for a single brick kind.
116+
117+
Returns a structurally valid PathCBrickScheduleDescriptor that the
118+
registry accepts and that downstream consumers can treat as an
119+
opaque-brick handoff. Never raises for unknown kinds — falls back to
120+
``opaque_passthrough`` family.
121+
"""
122+
category = _CATEGORY_BY_KIND.get(kind, "unknown")
123+
family = _FAMILY_BY_CATEGORY.get(category, "opaque_passthrough")
124+
is_opaque = family.startswith("opaque_")
125+
return PathCBrickScheduleDescriptor(
126+
op_name=kind,
127+
implementation_status=(
128+
"opaque_brick_passthrough" if is_opaque else "descriptor_synth_v4"
129+
),
130+
required_codegen_steps=_required_codegen_steps_for(kind, category),
131+
schedule_family=family,
132+
supports_backward=_supports_backward(category),
133+
description=(
134+
f"V4 fallback descriptor for {kind!r} (category={category}, "
135+
f"family={family}). Opaque bricks are grouped into regions "
136+
"via DLPack handoff; non-opaque get inline TileLang fragments."
137+
),
138+
production_source=f"cppmega_v4.models.unified_superblock_v4:BLOCK_BUILDERS[{kind!r}]",
139+
production_fragment_status=(
140+
"opaque_brick" if is_opaque else "v4_descriptor_synth_pending"
141+
),
142+
production_fragment_reason=(
143+
"auto-synthesized fallback; the brick already has a working "
144+
"Path B / mlx-lm SDPA implementation that the runtime calls "
145+
"via DLPack handoff. A future change can replace this with a "
146+
"real TileLang fragment when the schedule template lands."
147+
),
148+
)
149+
150+
151+
def build_v4_extended_registry() -> PathCBrickScheduleDescriptorRegistry:
152+
"""Return a registry covering both legacy v3 bricks and all V4 bricks.
153+
154+
Existing v3 descriptors win — we never clobber. Every key in
155+
BLOCK_BUILDERS that isn't already present gets a synthesised fallback
156+
descriptor. The result is a single registry that
157+
``compile_path_c_region`` can use without ``None`` lookups.
158+
"""
159+
registry = default_path_c_brick_schedule_descriptor_registry()
160+
for kind in BLOCK_BUILDERS:
161+
if registry.descriptor_for(kind) is not None:
162+
continue
163+
registry.register(synthesize_descriptor_for_brick(kind))
164+
return registry
165+
166+
167+
__all__ = [
168+
"build_v4_extended_registry",
169+
"synthesize_descriptor_for_brick",
170+
]

tests/v4/test_fusion_stage_b.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"""Stage B tests — descriptor_synthesizer + extended registry."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
7+
from cppmega_mlx.runtime.path_c_fusion_schedules import (
8+
PathCBrickScheduleDescriptor,
9+
PathCBrickScheduleDescriptorRegistry,
10+
default_path_c_brick_schedule_descriptor_registry,
11+
)
12+
from cppmega_v4.fusion.descriptor_synthesizer import (
13+
build_v4_extended_registry,
14+
synthesize_descriptor_for_brick,
15+
)
16+
from cppmega_v4.models.unified_superblock_v4 import BLOCK_BUILDERS
17+
18+
19+
# ---------------------------------------------------------------------------
20+
# Unit: per-brick synthesis
21+
# ---------------------------------------------------------------------------
22+
23+
24+
def test_synth_for_known_brick_returns_descriptor():
25+
d = synthesize_descriptor_for_brick("gdn")
26+
assert isinstance(d, PathCBrickScheduleDescriptor)
27+
assert d.op_name == "gdn"
28+
assert d.schedule_family == "linear_attn_scan"
29+
assert d.supports_backward is True
30+
assert len(d.required_codegen_steps) >= 1
31+
32+
33+
def test_synth_for_sdpa_brick_is_opaque():
34+
d = synthesize_descriptor_for_brick("gated_attention")
35+
assert d.schedule_family == "opaque_sdpa_with_outputs"
36+
assert d.implementation_status == "opaque_brick_passthrough"
37+
assert d.supports_backward is False
38+
39+
40+
def test_synth_for_unknown_kind_returns_safe_passthrough():
41+
"""Synthesizer must never raise for unknown kinds — produces an
42+
opaque_passthrough descriptor instead."""
43+
d = synthesize_descriptor_for_brick("totally_made_up_kind")
44+
assert d.op_name == "totally_made_up_kind"
45+
assert d.schedule_family == "opaque_passthrough"
46+
47+
48+
@pytest.mark.parametrize("kind", sorted(BLOCK_BUILDERS.keys()))
49+
def test_synth_for_every_block_builder_kind_is_structurally_valid(kind):
50+
"""Every kind in BLOCK_BUILDERS must produce a registry-acceptable
51+
descriptor (non-empty op_name, codegen steps, family)."""
52+
d = synthesize_descriptor_for_brick(kind)
53+
assert d.op_name == kind
54+
assert d.required_codegen_steps # non-empty
55+
assert d.schedule_family
56+
# registry.register raises on invalid descriptors
57+
PathCBrickScheduleDescriptorRegistry().register(d)
58+
59+
60+
# ---------------------------------------------------------------------------
61+
# Unit: build_v4_extended_registry
62+
# ---------------------------------------------------------------------------
63+
64+
65+
def test_extended_registry_includes_v3_legacy_descriptors():
66+
reg = build_v4_extended_registry()
67+
# The v3 legacy entries from default_path_c_brick_schedule_descriptor_registry
68+
legacy = ("mamba3_mimo", "residual_rmsnorm", "m2rnn",
69+
"attention_qkv_projection", "sparse_mla_fp8_apply")
70+
for op in legacy:
71+
d = reg.descriptor_for(op)
72+
assert d is not None, f"legacy descriptor {op!r} dropped"
73+
# legacy ones have a different status — not the v4 synth tag
74+
assert d.implementation_status != "opaque_brick_passthrough"
75+
76+
77+
def test_extended_registry_does_not_clobber_legacy_when_kind_collides():
78+
"""If a future BLOCK_BUILDERS key matches a legacy op_name, the
79+
legacy descriptor wins (descriptor_for returns it, not the synth)."""
80+
reg = build_v4_extended_registry()
81+
legacy_status = default_path_c_brick_schedule_descriptor_registry()\
82+
.descriptor_for("residual_rmsnorm").implementation_status
83+
assert reg.descriptor_for("residual_rmsnorm").implementation_status == legacy_status
84+
85+
86+
def test_extended_registry_covers_every_block_builder_kind():
87+
reg = build_v4_extended_registry()
88+
for kind in BLOCK_BUILDERS:
89+
d = reg.descriptor_for(kind)
90+
assert d is not None, f"missing descriptor for {kind!r}"
91+
92+
93+
def test_extended_registry_aot_backward_synthesis_only_for_supporting_kinds():
94+
"""The registry has a built-in :code:`<op>_bwd` synthesizer that only
95+
fires for descriptors with supports_backward=True. Verify the v4
96+
fallback descriptors respect that contract."""
97+
reg = build_v4_extended_registry()
98+
# gdn: supports_backward=True (linear_attn category)
99+
assert reg.descriptor_for("gdn_bwd") is not None
100+
# gated_attention: supports_backward=False (sdpa_attention category)
101+
assert reg.descriptor_for("gated_attention_bwd") is None
102+
103+
104+
# ---------------------------------------------------------------------------
105+
# System: signature lookup for a real Qwen3-Next-shaped chain
106+
# ---------------------------------------------------------------------------
107+
108+
109+
def test_system_descriptors_for_qwen3_next_chain():
110+
"""Build the descriptor list for a Qwen3-Next-style 5-brick chain."""
111+
reg = build_v4_extended_registry()
112+
signature = ("gdn", "gdn", "gdn", "gated_attention", "moe")
113+
descriptors = reg.descriptors_for_signature(signature)
114+
assert descriptors is not None
115+
assert len(descriptors) == len(signature)
116+
for op_name, d in zip(signature, descriptors):
117+
assert d.op_name == op_name
118+
119+
120+
def test_system_descriptors_for_ling26_chain():
121+
"""Ling 2.6's 7:1 bailing_linear + bailing_mla + bailing_moe."""
122+
reg = build_v4_extended_registry()
123+
signature = ("bailing_linear",) * 7 + ("bailing_mla", "bailing_moe")
124+
descriptors = reg.descriptors_for_signature(signature)
125+
assert descriptors is not None
126+
assert len(descriptors) == 9

0 commit comments

Comments
 (0)