Skip to content

Commit 1631426

Browse files
committed
feat(v4/spec): VBSpec Stage D — fusion-aware memory report
Adds cppmega_v4.spec.memory_report (VisualBuilderSpec.md §3.4, §6 D). estimate_memory(resolved, *, fusion_plan, dtype_bytes, optimizer, training, kv_cache_dtype_bytes, include_edge_handoff) rolls up: - weights (Σ params × dtype_bytes) - gradients (= weights, training only) - optimizer state (AdamW=8 b/param, Muon=4, none/sgd=0) - activations — FUSION-AWARE: bricks in one is_fused FusionRegionPlan share registers, so charged with max(), not sum(). This is what makes auto-fusion show up as a real memory win in the GUI. - kv_cache (only in inference; dtype byte width configurable for quantised caches, defaults to int8 = 1 b/elem) - edge handoff (one tensor materialisation per non-fused edge; fused boundaries pay zero. Toggleable for DLPack-perfect setups) Per-brick and per-region row dataclasses surface the breakdown for the GUI sidebar; MemoryReport.fits_on(device_hbm_bytes, headroom) gives the binary "влезет / не влезет" gate. Public surface re-exported from cppmega_v4.spec: - BrickMemoryRow, RegionMemoryRow, MemoryReport, estimate_memory Tests (tests/v4/test_vbspec_stage_d.py, 29 cases): - empty / size-1 invariants; argument validation - training vs inference deltas (grads / optimizer / kv-cache) - optimizer family comparison (AdamW > Muon > none) - kv-cache toggling via dtype-bytes=0 - FUSION: activations max-not-sum inside fused regions; params still summed; edge handoff drops for fused edges; toggleable - fits_on validation + binary smoke - system: every preset (12) under sensible env produces well-formed report with positive weights/activations and fits on 80 GB device Full v4 regression: 731 passed / 5 skipped / 0 failed.
1 parent 5339434 commit 1631426

3 files changed

Lines changed: 571 additions & 0 deletions

File tree

cppmega_v4/spec/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@
1616
insert_adapter_chain,
1717
suggest_adapter_chain,
1818
)
19+
from cppmega_v4.spec.memory_report import (
20+
BrickMemoryRow,
21+
MemoryReport,
22+
RegionMemoryRow,
23+
estimate_memory,
24+
)
1925
from cppmega_v4.spec.resolver import (
2026
DiagnosticSeverity,
2127
ResolvedBrickGraph,
@@ -36,14 +42,18 @@
3642
"ADAPTER_RULES",
3743
"AdapterRule",
3844
"AdapterSuggestion",
45+
"BrickMemoryRow",
3946
"BrickShapeContract",
4047
"DiagnosticSeverity",
48+
"MemoryReport",
49+
"RegionMemoryRow",
4150
"ResolveError",
4251
"ResolvedBrickGraph",
4352
"ResolvedEdge",
4453
"ShapeDiagnostic",
4554
"ShapeExpr",
4655
"contract_for",
56+
"estimate_memory",
4757
"insert_adapter_chain",
4858
"register_contract",
4959
"registered_kinds",

cppmega_v4/spec/memory_report.py

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
"""Memory roll-up over a ResolvedBrickGraph.
2+
3+
Given a graph that resolved cleanly under some ``dim_env``, this module
4+
produces a :class:`MemoryReport` with byte-level estimates for:
5+
6+
- weights (Σ params per brick × dtype_bytes)
7+
- gradients (= weights bytes; only if training=True)
8+
- optimizer state (AdamW: m + v at fp32 = 8 bytes/elem; Muon: 4)
9+
- activations (peak forward — fusion-aware: bricks inside one
10+
:class:`FusionRegionPlan` share registers, so we take the MAX of
11+
their per-brick activations, not the sum)
12+
- KV-cache (decode-mode only; can be quantised via
13+
``kv_cache_dtype_bytes``)
14+
- edge handoff (per-edge tensor materialisation between non-fused
15+
bricks — almost free under DLPack zero-copy, charged conservatively
16+
here as one full tensor allocation)
17+
18+
The report carries per-brick and per-region rows so the GUI can render
19+
both a sidebar total ("18.2 / 80 GB") and a detail view ("brick X is
20+
4 GB peak; region 0 saves 1.2 GB by fusing").
21+
22+
Public surface:
23+
- :class:`BrickMemoryRow` / :class:`RegionMemoryRow` / :class:`MemoryReport`
24+
- :func:`estimate_memory`
25+
"""
26+
27+
from __future__ import annotations
28+
29+
from collections.abc import Mapping, Sequence
30+
from dataclasses import dataclass, field
31+
from typing import Final
32+
33+
from cppmega_v4.fusion.auto_planner import FusionRegionPlan
34+
from cppmega_v4.spec.resolver import ResolvedBrickGraph
35+
from cppmega_v4.spec.shape_contract import (
36+
BrickShapeContract,
37+
contract_for,
38+
)
39+
40+
41+
# ---------------------------------------------------------------------------
42+
# Constants
43+
# ---------------------------------------------------------------------------
44+
45+
46+
_OPTIMIZER_BYTES_PER_PARAM: Final[dict[str, int]] = {
47+
# AdamW keeps two fp32 momenta (m, v) = 2 * 4 = 8 bytes/param
48+
"adamw": 8,
49+
# Muon stores Newton-Schulz state (fp32) ≈ 4 bytes/param
50+
"muon": 4,
51+
# No optimizer state (eval mode / SGD)
52+
"none": 0,
53+
"sgd": 0,
54+
}
55+
56+
57+
# ---------------------------------------------------------------------------
58+
# Row dataclasses
59+
# ---------------------------------------------------------------------------
60+
61+
62+
@dataclass(frozen=True)
63+
class BrickMemoryRow:
64+
name: str
65+
kind: str
66+
params_bytes: int
67+
activations_bytes: int
68+
kv_cache_bytes: int
69+
70+
71+
@dataclass(frozen=True)
72+
class RegionMemoryRow:
73+
region_idx: int
74+
brick_names: tuple[str, ...]
75+
params_bytes: int
76+
activations_bytes: int # max() across bricks for fused regions
77+
is_fused: bool
78+
79+
80+
@dataclass(frozen=True)
81+
class MemoryReport:
82+
"""Byte-level memory estimate for a Resolved BrickGraph."""
83+
84+
dim_env: Mapping[str, int]
85+
dtype_bytes: int
86+
optimizer: str
87+
training: bool
88+
kv_cache_dtype_bytes: int
89+
weights_bytes: int
90+
grads_bytes: int
91+
optimizer_bytes: int
92+
activations_bytes: int
93+
kv_cache_bytes: int
94+
edge_handoff_bytes: int
95+
total_bytes: int
96+
per_brick: dict[str, BrickMemoryRow]
97+
per_region: dict[int, RegionMemoryRow] = field(default_factory=dict)
98+
99+
def fits_on(
100+
self, device_hbm_bytes: int, *, headroom: float = 0.9,
101+
) -> bool:
102+
"""True if the estimate lands below ``device_hbm_bytes * headroom``."""
103+
if not 0.0 < headroom <= 1.0:
104+
raise ValueError(
105+
f"headroom must be in (0, 1], got {headroom!r}"
106+
)
107+
if device_hbm_bytes < 1:
108+
raise ValueError("device_hbm_bytes must be ≥ 1")
109+
return self.total_bytes <= device_hbm_bytes * headroom
110+
111+
def summary(self) -> dict[str, int]:
112+
"""Compact dict for logging / GUI bars."""
113+
return {
114+
"weights": self.weights_bytes,
115+
"grads": self.grads_bytes,
116+
"optimizer": self.optimizer_bytes,
117+
"activations": self.activations_bytes,
118+
"kv_cache": self.kv_cache_bytes,
119+
"edge": self.edge_handoff_bytes,
120+
"total": self.total_bytes,
121+
}
122+
123+
124+
# ---------------------------------------------------------------------------
125+
# Estimation
126+
# ---------------------------------------------------------------------------
127+
128+
129+
def _resolve_elem_count(expr_contract: BrickShapeContract, env, field_name):
130+
"""Resolve a 1-d ShapeExpr to a single non-negative int, or 0 on
131+
resolve failure (best-effort: opaque/missing-env bricks contribute
132+
0 to the corresponding bucket)."""
133+
expr = getattr(expr_contract, field_name)
134+
try:
135+
result = expr.resolve(env)
136+
except Exception:
137+
return 0
138+
return max(0, int(result[0]))
139+
140+
141+
def estimate_memory(
142+
resolved: ResolvedBrickGraph,
143+
*,
144+
fusion_plan: Sequence[FusionRegionPlan] | None = None,
145+
dtype_bytes: int = 2, # bf16
146+
optimizer: str = "adamw",
147+
training: bool = True,
148+
kv_cache_dtype_bytes: int = 1, # int8 quantised cache
149+
include_edge_handoff: bool = True,
150+
) -> MemoryReport:
151+
"""Roll up ``resolved`` into a :class:`MemoryReport`.
152+
153+
Args:
154+
resolved: the output of :func:`cppmega_v4.spec.resolve_shapes`.
155+
fusion_plan: when supplied, activations within one FusionRegionPlan
156+
are accounted with ``max`` (shared registers) instead of ``sum``.
157+
When None, all bricks are charged independently (worst case).
158+
dtype_bytes: bytes per weight / activation element (bf16=2).
159+
optimizer: "adamw" | "muon" | "none" / "sgd".
160+
training: when False, gradients and optimizer state are 0.
161+
kv_cache_dtype_bytes: 0 to disable KV cache accounting (training mode).
162+
include_edge_handoff: when False, edge tensors aren't charged
163+
(assumes perfect DLPack zero-copy across every boundary).
164+
"""
165+
if optimizer not in _OPTIMIZER_BYTES_PER_PARAM:
166+
raise ValueError(
167+
f"unknown optimizer {optimizer!r}; "
168+
f"choose from {sorted(_OPTIMIZER_BYTES_PER_PARAM)}"
169+
)
170+
if dtype_bytes < 1:
171+
raise ValueError("dtype_bytes must be ≥ 1")
172+
if kv_cache_dtype_bytes < 0:
173+
raise ValueError("kv_cache_dtype_bytes must be ≥ 0")
174+
175+
env = dict(resolved.dim_env)
176+
177+
per_brick: dict[str, BrickMemoryRow] = {}
178+
for node in resolved.original.nodes:
179+
try:
180+
c = contract_for(node.kind)
181+
except KeyError:
182+
# No contract → can't account; report a zero row.
183+
per_brick[node.name] = BrickMemoryRow(
184+
name=node.name, kind=node.kind,
185+
params_bytes=0, activations_bytes=0, kv_cache_bytes=0,
186+
)
187+
continue
188+
params_elems = _resolve_elem_count(c, env, "params_elems")
189+
act_elems = _resolve_elem_count(c, env, "activations_elems")
190+
kv_elems = _resolve_elem_count(c, env, "kv_cache_elems")
191+
per_brick[node.name] = BrickMemoryRow(
192+
name=node.name,
193+
kind=node.kind,
194+
params_bytes=params_elems * dtype_bytes,
195+
activations_bytes=act_elems * dtype_bytes,
196+
kv_cache_bytes=(
197+
kv_elems * kv_cache_dtype_bytes if training is False else 0
198+
),
199+
)
200+
201+
weights_bytes = sum(r.params_bytes for r in per_brick.values())
202+
grads_bytes = weights_bytes if training else 0
203+
optimizer_bytes = (
204+
sum(_OPTIMIZER_BYTES_PER_PARAM[optimizer]
205+
* (r.params_bytes // dtype_bytes)
206+
for r in per_brick.values())
207+
if training else 0
208+
)
209+
210+
# Activations — fusion-aware when a plan is supplied.
211+
per_region: dict[int, RegionMemoryRow] = {}
212+
if fusion_plan:
213+
accounted: set[str] = set()
214+
for idx, plan in enumerate(fusion_plan):
215+
rows = [per_brick[n] for n in plan.brick_names if n in per_brick]
216+
if not rows:
217+
continue
218+
if plan.is_fused:
219+
region_act = max(r.activations_bytes for r in rows)
220+
else:
221+
region_act = sum(r.activations_bytes for r in rows)
222+
per_region[idx] = RegionMemoryRow(
223+
region_idx=idx,
224+
brick_names=plan.brick_names,
225+
params_bytes=sum(r.params_bytes for r in rows),
226+
activations_bytes=region_act,
227+
is_fused=plan.is_fused,
228+
)
229+
accounted.update(r.name for r in rows)
230+
# Unaccounted (e.g. adapter nodes inserted after plan was built)
231+
# contribute their own activations as-is.
232+
leftover = sum(
233+
r.activations_bytes for n, r in per_brick.items() if n not in accounted
234+
)
235+
activations_bytes = (
236+
sum(rr.activations_bytes for rr in per_region.values()) + leftover
237+
)
238+
else:
239+
activations_bytes = sum(r.activations_bytes for r in per_brick.values())
240+
241+
kv_cache_bytes = sum(r.kv_cache_bytes for r in per_brick.values())
242+
243+
# Edge handoff: each non-fused edge moves one tensor; estimate by the
244+
# bigger of producer/consumer activation rows on the edge.
245+
edge_handoff_bytes = 0
246+
if include_edge_handoff:
247+
fused_pairs: set[tuple[str, str]] = set()
248+
if fusion_plan:
249+
for plan in fusion_plan:
250+
if not plan.is_fused:
251+
continue
252+
names = plan.brick_names
253+
for i in range(len(names) - 1):
254+
fused_pairs.add((names[i], names[i + 1]))
255+
for edge in resolved.edges:
256+
if (edge.producer, edge.consumer) in fused_pairs:
257+
continue
258+
p = per_brick.get(edge.producer)
259+
c = per_brick.get(edge.consumer)
260+
if p is None or c is None:
261+
continue
262+
edge_handoff_bytes += max(p.activations_bytes, c.activations_bytes)
263+
264+
total_bytes = (
265+
weights_bytes + grads_bytes + optimizer_bytes
266+
+ activations_bytes + kv_cache_bytes + edge_handoff_bytes
267+
)
268+
269+
return MemoryReport(
270+
dim_env=env,
271+
dtype_bytes=dtype_bytes,
272+
optimizer=optimizer,
273+
training=training,
274+
kv_cache_dtype_bytes=kv_cache_dtype_bytes,
275+
weights_bytes=weights_bytes,
276+
grads_bytes=grads_bytes,
277+
optimizer_bytes=optimizer_bytes,
278+
activations_bytes=activations_bytes,
279+
kv_cache_bytes=kv_cache_bytes,
280+
edge_handoff_bytes=edge_handoff_bytes,
281+
total_bytes=total_bytes,
282+
per_brick=per_brick,
283+
per_region=per_region,
284+
)
285+
286+
287+
__all__ = [
288+
"BrickMemoryRow",
289+
"MemoryReport",
290+
"RegionMemoryRow",
291+
"estimate_memory",
292+
]

0 commit comments

Comments
 (0)