Skip to content

Commit 7700f3b

Browse files
committed
feat(v4/fusion): Stage C — greedy auto_planner for fusion regions
Adds cppmega_v4.fusion.auto_planner with a bottom-up greedy region walker that turns a BrickGraph into a list of FusionRegionPlans. - plan_fusion_regions(graph, *, max_region_size, max_shared_mem_bytes) Walks nodes in declaration order; for each adjacent pair consults can_fuse_pair() and extends the open region iff eligibility says "yes" AND the region won't breach a hard limit. Closes and starts a new region otherwise. Every node is covered exactly once. - FusionRegionPlan dataclass: brick_names, categories, backend (path_c | metal_inline | dlpack_handoff), estimated_savings_us, and a human-readable close reason for telemetry. - Hard limits (defaults): max_region_size = 8 bricks (Apple Metal register pressure) max_shared_mem_bytes = 32 KiB (per-region threadgroup memory) Per-category footprint estimates feed the shared-mem check. - Cost model (telemetry-only, never forces a split): savings_us = (n-1) * (launch_overhead + handoff_overhead) - (distinct_categories - 1) * mix_penalty Size-1 and dlpack-handoff regions report zero savings. - auto_fuse_model(model) attaches the planned regions to model._v4_fusion_plan (+ ._v4_fusion_brick_graph). Stage E will consume that to drive actual Path C compilation; until then the annotation is enough for downstream lowering experiments. Tests (tests/v4/test_fusion_stage_c.py, 23 cases): - dataclass invariants (empty region rejected, alignment enforced) - planner edge cases (empty graph, size-1, invalid limits) - greedy grouping (3 GDN fuse, SDPA boundary, MoE-vs-attention boundary, sparse_attn isolates) - hard limits (max_region_size splits long chains; shared-mem cap splits at 2-per-region; default 8x4KiB exactly fits 32KiB) - cost model (zero for solo / handoff; grows with size; mix penalty) - system: Qwen3-Next 3:1 pattern, Ling 2.6 7:1 pattern, Kimi Linear 3:1 pattern over two repeat-units - auto_fuse_model annotation invariants Full v4 regression: 473 passed / 5 skipped / 0 failed.
1 parent 73f8354 commit 7700f3b

3 files changed

Lines changed: 693 additions & 0 deletions

File tree

cppmega_v4/fusion/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@
1717

1818
from __future__ import annotations
1919

20+
from cppmega_v4.fusion.auto_planner import (
21+
DEFAULT_MAX_REGION_SIZE,
22+
DEFAULT_MAX_SHARED_MEM_BYTES,
23+
FusionRegionPlan,
24+
auto_fuse_model,
25+
plan_fusion_regions,
26+
)
2027
from cppmega_v4.fusion.brick_graph import (
2128
BrickGraph,
2229
BrickNode,
@@ -39,16 +46,21 @@
3946
)
4047

4148
__all__ = [
49+
"DEFAULT_MAX_REGION_SIZE",
50+
"DEFAULT_MAX_SHARED_MEM_BYTES",
4251
"BrickGraph",
4352
"BrickNode",
4453
"FusionEligibility",
54+
"FusionRegionPlan",
55+
"auto_fuse_model",
4556
"build_v4_extended_registry",
4657
"can_fuse_pair",
4758
"dlpack_available",
4859
"from_block_specs",
4960
"from_mlx_model",
5061
"host_copy_fallback",
5162
"mlx_to_tilelang",
63+
"plan_fusion_regions",
5264
"synthesize_descriptor_for_brick",
5365
"tilelang_to_mlx",
5466
]

cppmega_v4/fusion/auto_planner.py

Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
"""Stage C — greedy fusion-region planner over a BrickGraph.
2+
3+
Walks a ``BrickGraph`` left-to-right and groups adjacent fusion-eligible
4+
bricks into ``FusionRegionPlan`` records. The planner is intentionally
5+
conservative:
6+
7+
- Eligibility check delegates entirely to
8+
:func:`cppmega_v4.fusion.compatibility.can_fuse_pair` (table-driven).
9+
- A region is **extended** when the trailing brick can fuse with the
10+
next candidate AND no hard limit is breached.
11+
- A region is **closed** (and a new one started at the next brick)
12+
when eligibility says ``can_fuse=False``, when adding the next brick
13+
would exceed ``max_region_size`` bricks, or when the estimated
14+
shared-memory footprint would exceed ``max_shared_mem_bytes``.
15+
16+
A simple cost model estimates microsecond savings of grouping (saved
17+
kernel launches + saved DLPack handoffs) minus a register-pressure
18+
penalty for category-mixed regions. The cost model never **forces** a
19+
split — it only reports an ``estimated_savings_us`` field for telemetry
20+
and downstream cost-aware tuning.
21+
22+
Public surface:
23+
- :class:`FusionRegionPlan`
24+
- :func:`plan_fusion_regions`
25+
- :func:`auto_fuse_model` — annotates an :class:`mlx.nn.Module` with
26+
its planned regions (does not yet replace child modules; the actual
27+
Path C compilation lands in Stage E).
28+
"""
29+
30+
from __future__ import annotations
31+
32+
from collections.abc import Sequence
33+
from dataclasses import dataclass, field
34+
35+
import mlx.nn as nn
36+
37+
from cppmega_v4.fusion.brick_graph import BrickGraph, BrickNode, from_mlx_model
38+
from cppmega_v4.fusion.compatibility import (
39+
FusionEligibility,
40+
_CATEGORY_BY_KIND,
41+
can_fuse_pair,
42+
)
43+
44+
45+
# ---------------------------------------------------------------------------
46+
# Hard limits and cost-model constants
47+
# ---------------------------------------------------------------------------
48+
49+
DEFAULT_MAX_REGION_SIZE: int = 8
50+
"""Maximum bricks per region. Apple Metal register pressure starts hurting
51+
past ~8 fused passes; beyond that, codegen often spills to threadgroup
52+
memory or fragments live ranges across kernels."""
53+
54+
DEFAULT_MAX_SHARED_MEM_BYTES: int = 32 * 1024
55+
"""Per-region Apple Metal threadgroup memory budget (32 KiB)."""
56+
57+
# Per-brick estimated threadgroup-memory footprint, in bytes. These are
58+
# rough rule-of-thumb numbers used by the planner's hard-limit check; the
59+
# real Path C lowering computes exact figures during codegen.
60+
_SHARED_MEM_ESTIMATE_BY_CATEGORY: dict[str, int] = {
61+
"linear_attn": 4096, # recurrent state per head
62+
"ssm": 8192, # chunkwise scan buffer
63+
"sdpa_attention": 2048, # output gate fragment
64+
"cross_attn": 2048,
65+
"nonlinear_rnn": 4096,
66+
"moe": 4096, # route + combine buffers
67+
"sparse_attn": 4096,
68+
"norm_or_proj": 1024,
69+
"unknown": 2048,
70+
}
71+
72+
# Cost-model constants (microseconds, derived from rough Apple M-series
73+
# launch-latency measurements). Used only for telemetry; the planner's
74+
# decision is driven by eligibility + hard limits.
75+
_KERNEL_LAUNCH_OVERHEAD_US: float = 5.0
76+
_DLPACK_HANDOFF_OVERHEAD_US: float = 1.0
77+
_REGISTER_PRESSURE_PENALTY_PER_MIX_US: float = 1.5
78+
79+
80+
# ---------------------------------------------------------------------------
81+
# FusionRegionPlan
82+
# ---------------------------------------------------------------------------
83+
84+
85+
@dataclass(frozen=True)
86+
class FusionRegionPlan:
87+
"""A planned fusion region — a contiguous slice of brick names that
88+
will be compiled together (or, for size-1 regions, kept as a single
89+
standalone brick).
90+
91+
Fields:
92+
brick_names: ordered tuple of node names belonging to this region.
93+
categories: per-brick category (linear_attn / sdpa_attention / ...).
94+
backend: ``"path_c"`` | ``"metal_inline"`` | ``"dlpack_handoff"``
95+
— the codegen backend the planner selected. Size-1 regions
96+
always carry the brick's own native backend (single-brick passes
97+
through whatever it already implements).
98+
estimated_savings_us: positive when the planner expects time saved
99+
by fusing this region vs running its bricks separately. Always 0
100+
for size-1 regions.
101+
reason: short human-readable explanation (used by tests/telemetry
102+
to surface *why* a region was closed at this boundary).
103+
"""
104+
105+
brick_names: tuple[str, ...]
106+
categories: tuple[str, ...]
107+
backend: str
108+
estimated_savings_us: float
109+
reason: str
110+
111+
def __post_init__(self) -> None:
112+
if not self.brick_names:
113+
raise ValueError("FusionRegionPlan must contain ≥1 brick")
114+
if len(self.brick_names) != len(self.categories):
115+
raise ValueError(
116+
"FusionRegionPlan: brick_names and categories must align"
117+
)
118+
119+
@property
120+
def size(self) -> int:
121+
return len(self.brick_names)
122+
123+
@property
124+
def is_fused(self) -> bool:
125+
"""True when this region groups ≥2 bricks under a fusing backend."""
126+
return self.size > 1 and self.backend in {"path_c", "metal_inline"}
127+
128+
129+
# ---------------------------------------------------------------------------
130+
# Cost model
131+
# ---------------------------------------------------------------------------
132+
133+
134+
def _shared_mem_estimate(category: str) -> int:
135+
return _SHARED_MEM_ESTIMATE_BY_CATEGORY.get(category, 2048)
136+
137+
138+
def _estimate_savings_us(categories: Sequence[str], backend: str) -> float:
139+
"""Estimated microseconds saved by fusing this region vs running each
140+
brick as a standalone kernel. Returns 0.0 for size-1 regions and for
141+
dlpack-only regions (no real fusion happens there)."""
142+
n = len(categories)
143+
if n <= 1:
144+
return 0.0
145+
if backend not in {"path_c", "metal_inline"}:
146+
return 0.0
147+
saved_launches = (n - 1) * _KERNEL_LAUNCH_OVERHEAD_US
148+
saved_handoffs = (n - 1) * _DLPACK_HANDOFF_OVERHEAD_US
149+
distinct_categories = len(set(categories))
150+
mix_penalty = (
151+
(distinct_categories - 1) * _REGISTER_PRESSURE_PENALTY_PER_MIX_US
152+
)
153+
return max(0.0, saved_launches + saved_handoffs - mix_penalty)
154+
155+
156+
# ---------------------------------------------------------------------------
157+
# Planner core
158+
# ---------------------------------------------------------------------------
159+
160+
161+
@dataclass
162+
class _OpenRegion:
163+
"""Mutable scratch state for the greedy walker."""
164+
165+
nodes: list[BrickNode] = field(default_factory=list)
166+
backend: str = "dlpack_handoff"
167+
shared_mem_bytes: int = 0
168+
close_reason: str = "end of graph"
169+
170+
171+
def _category_of(node: BrickNode) -> str:
172+
return _CATEGORY_BY_KIND.get(node.kind, "unknown")
173+
174+
175+
def _finalise(region: _OpenRegion) -> FusionRegionPlan:
176+
cats = tuple(_category_of(n) for n in region.nodes)
177+
return FusionRegionPlan(
178+
brick_names=tuple(n.name for n in region.nodes),
179+
categories=cats,
180+
backend=region.backend,
181+
estimated_savings_us=_estimate_savings_us(cats, region.backend),
182+
reason=region.close_reason,
183+
)
184+
185+
186+
def plan_fusion_regions(
187+
graph: BrickGraph,
188+
*,
189+
max_region_size: int = DEFAULT_MAX_REGION_SIZE,
190+
max_shared_mem_bytes: int = DEFAULT_MAX_SHARED_MEM_BYTES,
191+
) -> list[FusionRegionPlan]:
192+
"""Greedy bottom-up region planner.
193+
194+
Walks ``graph.nodes`` in declaration order. Starts a new region at
195+
the first node, then for each subsequent node decides whether to
196+
*extend* the open region or *close* it and open a new one.
197+
198+
The decision is:
199+
200+
1. If ``can_fuse_pair(tail, next).can_fuse`` is False → close.
201+
2. If extending would put the region above ``max_region_size`` → close.
202+
3. If extending would push estimated threadgroup memory above
203+
``max_shared_mem_bytes`` → close.
204+
4. Otherwise extend.
205+
206+
Returns a list of :class:`FusionRegionPlan` covering every node in
207+
``graph`` exactly once, in original order.
208+
"""
209+
if max_region_size < 1:
210+
raise ValueError("max_region_size must be ≥ 1")
211+
if max_shared_mem_bytes < 1:
212+
raise ValueError("max_shared_mem_bytes must be ≥ 1")
213+
214+
plans: list[FusionRegionPlan] = []
215+
if not graph.nodes:
216+
return plans
217+
218+
open_region = _OpenRegion()
219+
first = graph.nodes[0]
220+
open_region.nodes.append(first)
221+
open_region.shared_mem_bytes = _shared_mem_estimate(_category_of(first))
222+
# A size-1 region carries the "passthrough" backend; it gets upgraded
223+
# to path_c / metal_inline if (and only if) a fusable neighbour
224+
# extends it below.
225+
open_region.backend = "dlpack_handoff"
226+
227+
for prev, current in zip(graph.nodes[:-1], graph.nodes[1:]):
228+
tail = open_region.nodes[-1]
229+
elig: FusionEligibility = can_fuse_pair(tail, current)
230+
next_shared = _shared_mem_estimate(_category_of(current))
231+
232+
close = False
233+
reason = ""
234+
if not elig.can_fuse:
235+
close = True
236+
reason = elig.reason
237+
elif len(open_region.nodes) + 1 > max_region_size:
238+
close = True
239+
reason = (
240+
f"region would exceed max_region_size={max_region_size}"
241+
)
242+
elif open_region.shared_mem_bytes + next_shared > max_shared_mem_bytes:
243+
close = True
244+
reason = (
245+
"region would exceed shared-mem budget "
246+
f"({open_region.shared_mem_bytes + next_shared} > "
247+
f"{max_shared_mem_bytes} bytes)"
248+
)
249+
250+
if close:
251+
open_region.close_reason = reason
252+
plans.append(_finalise(open_region))
253+
open_region = _OpenRegion()
254+
open_region.nodes.append(current)
255+
open_region.shared_mem_bytes = next_shared
256+
open_region.backend = "dlpack_handoff"
257+
else:
258+
open_region.nodes.append(current)
259+
open_region.shared_mem_bytes += next_shared
260+
# Promote backend to the chosen fusing backend (path_c or
261+
# metal_inline). The first promotion wins; subsequent fuses
262+
# within the same region must agree on the same backend.
263+
if open_region.backend == "dlpack_handoff":
264+
open_region.backend = elig.backend
265+
elif open_region.backend != elig.backend:
266+
# Mixed-backend region — must close, start fresh.
267+
open_region.nodes.pop()
268+
open_region.shared_mem_bytes -= next_shared
269+
open_region.close_reason = (
270+
f"backend mismatch: region={open_region.backend!r} "
271+
f"vs pair={elig.backend!r}"
272+
)
273+
plans.append(_finalise(open_region))
274+
open_region = _OpenRegion()
275+
open_region.nodes.append(current)
276+
open_region.shared_mem_bytes = next_shared
277+
open_region.backend = "dlpack_handoff"
278+
279+
plans.append(_finalise(open_region))
280+
return plans
281+
282+
283+
# ---------------------------------------------------------------------------
284+
# Public model-level entry point
285+
# ---------------------------------------------------------------------------
286+
287+
288+
def auto_fuse_model(
289+
model: nn.Module,
290+
*,
291+
max_region_size: int = DEFAULT_MAX_REGION_SIZE,
292+
max_shared_mem_bytes: int = DEFAULT_MAX_SHARED_MEM_BYTES,
293+
) -> nn.Module:
294+
"""Annotate ``model`` with its planned fusion regions.
295+
296+
Walks the model's direct children, builds a :class:`BrickGraph` from
297+
them (see :func:`cppmega_v4.fusion.brick_graph.from_mlx_model`),
298+
runs :func:`plan_fusion_regions`, and attaches the resulting list
299+
to ``model._v4_fusion_plan``.
300+
301+
The model object is returned unchanged structurally — Stage E will
302+
add the actual region compilation + module replacement step. Until
303+
then, downstream consumers can read ``model._v4_fusion_plan`` to
304+
drive their own lowering.
305+
"""
306+
graph = from_mlx_model(model)
307+
plan = plan_fusion_regions(
308+
graph,
309+
max_region_size=max_region_size,
310+
max_shared_mem_bytes=max_shared_mem_bytes,
311+
)
312+
setattr(model, "_v4_fusion_plan", tuple(plan))
313+
setattr(model, "_v4_fusion_brick_graph", graph)
314+
return model
315+
316+
317+
__all__ = [
318+
"DEFAULT_MAX_REGION_SIZE",
319+
"DEFAULT_MAX_SHARED_MEM_BYTES",
320+
"FusionRegionPlan",
321+
"auto_fuse_model",
322+
"plan_fusion_regions",
323+
]

0 commit comments

Comments
 (0)