Skip to content

Commit 2917632

Browse files
committed
feat(v8-r02): architectures.scale_down + GalleryScaleDownSlider
Closes cppmega-mlx-yz6n.2. Drops cppmega_v4/architectures/scale_down.py that binary-searches the largest (hidden_size, num_layers) of a Raschka preset whose verify_and_estimate cost ≤ target_bytes. Family-fallback when the preset isn't in the canonical-size table. New JSON-RPC route: architectures.scale_down(preset, target_bytes, min_hidden?, min_layers?) → {hidden_size, num_layers, estimated_bytes, target_bytes, fits, scaled_down_from, specs}. Wired into dispatcher.py + METHOD_REGISTRY. UI: new GalleryScaleDownSlider mounted above GalleryTab — preset picker, range input (64MB…64GB, debounced 250ms), live preview cells (target / est / fits-badge / shape), Apply button that swaps the canvas with the scaled specs + updates dim_env.H. AC met: scale_down('llama3_8b', 1GB) lands at H=512 L=32 ≈ 821MB — fits=True, ≥ 0.5GB. Tests: 10/10 new pytest (binary-search bounds, RPC end-to-end, unknown- preset fallback, validation errors), 3/3 new vitest (slider mount, fits-false disables Apply, RPC error renders). Regression: 891 pytest + 455 vitest all green.
1 parent 023f800 commit 2917632

8 files changed

Lines changed: 856 additions & 1 deletion

File tree

Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
"""V8-R02: ``scale_down(preset, target_bytes)`` — binary search the
2+
smallest ``(hidden_size, num_layers)`` pair whose estimated training
3+
memory fits inside a target byte budget.
4+
5+
Used by the V8 GalleryScaleDownSlider UI: drag the slider to a target
6+
(e.g. 1 GB), get a 1B-parameter llama3_8b instead of the full 8B, drop
7+
it on the canvas, train.
8+
9+
Cost model: ``cppmega_v4.spec.verify_and_estimate`` with bf16 dtype +
10+
adamw + ``training=True`` — same surface the rest of v4 uses for memory
11+
budgets, so the slider matches the MemoryBar.
12+
13+
The search is deterministic and total:
14+
15+
* If the smallest reachable size (``min_hidden`` × ``min_layers``)
16+
already exceeds the budget, returns that minimum with
17+
``fits=False``.
18+
* Otherwise returns the largest ``(H, L)`` on a coarse-then-refine
19+
grid that still fits, plus the exact original ``(H, L)`` so the UI
20+
can show how aggressively we scaled.
21+
"""
22+
23+
from __future__ import annotations
24+
25+
from dataclasses import dataclass
26+
from typing import Any
27+
28+
from cppmega_v4.architectures.presets import PRESETS, _attn_params
29+
from cppmega_v4.fusion.brick_graph import from_block_specs
30+
from cppmega_v4.spec.api import verify_and_estimate
31+
32+
33+
__all__ = ["ScaleDownResult", "scale_down", "build_preset_specs_scaled"]
34+
35+
36+
# Canonical full-size (hidden, layers) for each preset family.
37+
# Source: paper/blog references threaded through preset_training_defaults.
38+
# Used as the upper bound for the binary search and as a record of what
39+
# was scaled down from. Family-fallback is keyed on prefix (see
40+
# ``_canonical_size``).
41+
_CANONICAL_SIZE: dict[str, tuple[int, int]] = {
42+
# Llama 3
43+
"llama3_8b": (4096, 32),
44+
"llama3_2_1b": (2048, 16),
45+
"llama3_2_3b": (3072, 28),
46+
"llama4_maverick": (8192, 32),
47+
# Qwen 3
48+
"qwen3_dense_0_6b": (1024, 12),
49+
"qwen3_dense_4b": (2560, 28),
50+
"qwen3_dense_8b": (4096, 32),
51+
"qwen3_dense_32b": (5120, 64),
52+
"qwen3_30b_a3b": (4096, 48),
53+
"qwen3_235b_a22b": (8192, 88),
54+
"qwen3_next": (4096, 32),
55+
# DeepSeek / Kimi
56+
"deepseek_v3": (7168, 61),
57+
"deepseek_v4_flash": (4096, 32),
58+
"kimi_k2": (7168, 61),
59+
"kimi_linear": (4096, 32),
60+
# Mistral / Phi / Granite
61+
"mistral_small_3_1": (4096, 32),
62+
"phi4": (5120, 32),
63+
"granite_4_1": (4096, 32),
64+
"nanbeige_4_1": (4096, 32),
65+
# OLMo
66+
"olmo2_7b": (4096, 32),
67+
"olmo3_7b": (4096, 32),
68+
"olmo3_32b": (5120, 64),
69+
# GLM
70+
"glm_45": (4096, 32),
71+
"glm_47": (4096, 32),
72+
"glm_5": (5120, 60),
73+
# Gemma
74+
"gemma4": (3072, 26),
75+
"gemma3_27b": (5376, 64),
76+
"gemma3_270m": (640, 18),
77+
"gemma4_31b": (5376, 64),
78+
"gemma_4_e2b": (2304, 26),
79+
"gemma_4_e4b": (3584, 44),
80+
# OSS / sliding-MoE
81+
"gpt_oss_20b": (4096, 32),
82+
"gpt_oss_120b": (8192, 64),
83+
"grok25": (6144, 48),
84+
# SmolLM / GPT-2 / xLSTM
85+
"smollm3": (2048, 30),
86+
"gpt2_xl": (1600, 48),
87+
"xlstm_7b": (4096, 32),
88+
# Misc
89+
"nemotron3": (4096, 32),
90+
"zaya1": (4096, 32),
91+
"longcat": (4096, 32),
92+
"ling25": (4096, 32),
93+
"tiny_aya": (768, 12),
94+
"minimax_m2": (4096, 32),
95+
"mimo_v2_5": (4096, 32),
96+
}
97+
98+
99+
def _canonical_size(preset: str) -> tuple[int, int]:
100+
"""Resolve the canonical (H, L) for ``preset``, with family fallback."""
101+
if preset in _CANONICAL_SIZE:
102+
return _CANONICAL_SIZE[preset]
103+
# Longest matching prefix wins (same shape as
104+
# preset_training_defaults.get_defaults).
105+
matches = [k for k in _CANONICAL_SIZE if preset.startswith(k.split("_")[0])]
106+
if matches:
107+
return _CANONICAL_SIZE[max(matches, key=len)]
108+
return (4096, 32) # generic Llama-3-shape fallback
109+
110+
111+
@dataclass(frozen=True)
112+
class ScaleDownResult:
113+
"""Result of :func:`scale_down`.
114+
115+
Attributes:
116+
hidden_size: chosen H (the largest that fits, or ``min_hidden``).
117+
num_layers: chosen L.
118+
estimated_bytes: cost-model estimate at ``(H, L)`` in bf16+adamw.
119+
target_bytes: the budget that was requested.
120+
fits: whether ``estimated_bytes <= target_bytes`` at the chosen size.
121+
scaled_down_from: canonical full-size ``(H, L)`` for the preset.
122+
specs: scaled-and-repeated wire-form specs (length = L). Empty when
123+
instantiation is skipped.
124+
"""
125+
126+
hidden_size: int
127+
num_layers: int
128+
estimated_bytes: int
129+
target_bytes: int
130+
fits: bool
131+
scaled_down_from: tuple[int, int]
132+
specs: list[dict[str, Any]]
133+
134+
def to_wire(self) -> dict[str, Any]:
135+
return {
136+
"hidden_size": self.hidden_size,
137+
"num_layers": self.num_layers,
138+
"estimated_bytes": self.estimated_bytes,
139+
"target_bytes": self.target_bytes,
140+
"fits": self.fits,
141+
"scaled_down_from": {
142+
"hidden_size": self.scaled_down_from[0],
143+
"num_layers": self.scaled_down_from[1],
144+
},
145+
"specs": self.specs,
146+
}
147+
148+
149+
# Powers-of-two H candidates between min and canonical, plus the
150+
# canonical exactly. Layer counts are sampled coarsely to avoid blowing
151+
# up the search.
152+
def _h_candidates(min_h: int, max_h: int) -> list[int]:
153+
h = max(min_h, 64)
154+
cs: list[int] = []
155+
while h <= max_h:
156+
cs.append(h)
157+
h *= 2
158+
if max_h not in cs:
159+
cs.append(max_h)
160+
return cs
161+
162+
163+
def _l_candidates(min_l: int, max_l: int) -> list[int]:
164+
cs: list[int] = []
165+
# Linear in log-space: min, 2*min, 4*min, ..., max
166+
l = max(min_l, 1)
167+
while l <= max_l:
168+
cs.append(l)
169+
l *= 2
170+
if max_l not in cs:
171+
cs.append(max_l)
172+
return cs
173+
174+
175+
def _attention_only_specs(hidden_size: int) -> list[dict[str, Any]]:
176+
"""Minimal repeat-unit: attention + mlp. Same shape used by
177+
every llama-like preset, so it gives a stable per-layer cost.
178+
"""
179+
return [
180+
{"kind": "attention", "name": "attn",
181+
"params": _attn_params(hidden_size)},
182+
{"kind": "mlp", "name": "mlp"},
183+
]
184+
185+
186+
def _estimate_bytes(
187+
preset: str, hidden_size: int, num_layers: int,
188+
) -> int:
189+
"""Build the scaled preset's first L=num_layers repeats, run
190+
verify_and_estimate, and return its peak training-memory bytes."""
191+
factory = PRESETS.get(preset)
192+
if factory is None:
193+
unit = _attention_only_specs(hidden_size)
194+
else:
195+
unit = factory(hidden_size)
196+
# Stamp the unit ``num_layers`` times, renaming bricks per layer so
197+
# the BrickGraph remains acyclic and uniquely-named.
198+
specs: list[dict[str, Any]] = []
199+
for li in range(num_layers):
200+
for s in unit:
201+
spec = dict(s)
202+
base = spec.get("name") or spec.get("kind", "brick")
203+
spec["name"] = f"{base}_L{li}"
204+
# parallel-blocks carry no top-level "name"; rename leaves
205+
if "parallel" in spec:
206+
spec["parallel"] = [
207+
{**leaf, "name":
208+
f"{leaf.get('name', leaf.get('kind', 'leaf'))}_L{li}"}
209+
for leaf in spec["parallel"]
210+
]
211+
specs.append(spec)
212+
graph = from_block_specs(specs, hidden_size=hidden_size, instantiate=False)
213+
res = verify_and_estimate(
214+
graph, dim_env={"H": hidden_size, "B": 1, "S": 256},
215+
training=True, optimizer="adamw", dtype_bytes=2,
216+
)
217+
return int(res.memory.total_bytes)
218+
219+
220+
def scale_down(
221+
preset: str,
222+
target_bytes: int,
223+
*,
224+
min_hidden: int = 64,
225+
min_layers: int = 1,
226+
) -> ScaleDownResult:
227+
"""Find the largest ``(H, L)`` ≤ canonical whose estimated training
228+
memory ≤ ``target_bytes``.
229+
230+
Search is a coarse log-grid over both axes — exact enough for the
231+
UI slider (where the user sees a live estimate within ~10% of the
232+
budget) and cheap enough to call on every slider tick.
233+
"""
234+
if target_bytes <= 0:
235+
raise ValueError(f"target_bytes must be > 0, got {target_bytes}")
236+
canon_h, canon_l = _canonical_size(preset)
237+
h_grid = _h_candidates(min_hidden, canon_h)
238+
l_grid = _l_candidates(min_layers, canon_l)
239+
240+
best: tuple[int, int, int] | None = None # (H, L, bytes)
241+
fallback: tuple[int, int, int] | None = None
242+
243+
for h in h_grid:
244+
for l_ in l_grid:
245+
try:
246+
est = _estimate_bytes(preset, h, l_)
247+
except Exception:
248+
continue
249+
cand = (h, l_, est)
250+
if est <= target_bytes:
251+
if best is None or h * l_ > best[0] * best[1]:
252+
best = cand
253+
else:
254+
# Smallest over-budget as fallback when *nothing* fits.
255+
if fallback is None or est < fallback[2]:
256+
fallback = cand
257+
if best is not None:
258+
h, l_, est = best
259+
fits = True
260+
elif fallback is not None:
261+
h, l_, est = fallback
262+
fits = False
263+
else:
264+
# Cost model never converged — emit the minimum with fits=False.
265+
h, l_, est = min_hidden, min_layers, 0
266+
fits = False
267+
268+
# Build the final scaled specs (this is what UI drops onto the canvas).
269+
factory = PRESETS.get(preset)
270+
if factory is None:
271+
unit = _attention_only_specs(h)
272+
else:
273+
unit = factory(h)
274+
specs: list[dict[str, Any]] = []
275+
for li in range(l_):
276+
for s in unit:
277+
spec = dict(s)
278+
base = spec.get("name") or spec.get("kind", "brick")
279+
spec["name"] = f"{base}_L{li}"
280+
if "parallel" in spec:
281+
spec["parallel"] = [
282+
{**leaf, "name":
283+
f"{leaf.get('name', leaf.get('kind', 'leaf'))}_L{li}"}
284+
for leaf in spec["parallel"]
285+
]
286+
specs.append(spec)
287+
return ScaleDownResult(
288+
hidden_size=h, num_layers=l_,
289+
estimated_bytes=est, target_bytes=target_bytes,
290+
fits=fits, scaled_down_from=(canon_h, canon_l),
291+
specs=specs,
292+
)
293+
294+
295+
def build_preset_specs_scaled(
296+
preset: str, hidden_size: int, num_layers: int,
297+
) -> list[dict[str, Any]]:
298+
"""Convenience wrapper: stamp the preset unit num_layers times at
299+
a given hidden_size. Used by the auto-fit path in R04."""
300+
factory = PRESETS.get(preset)
301+
if factory is None:
302+
raise ValueError(f"unknown preset {preset!r}")
303+
unit = factory(hidden_size)
304+
specs: list[dict[str, Any]] = []
305+
for li in range(num_layers):
306+
for s in unit:
307+
spec = dict(s)
308+
base = spec.get("name") or spec.get("kind", "brick")
309+
spec["name"] = f"{base}_L{li}"
310+
if "parallel" in spec:
311+
spec["parallel"] = [
312+
{**leaf, "name":
313+
f"{leaf.get('name', leaf.get('kind', 'leaf'))}_L{li}"}
314+
for leaf in spec["parallel"]
315+
]
316+
specs.append(spec)
317+
return specs

cppmega_v4/jsonrpc/dispatcher.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from cppmega_v4.jsonrpc.methods import (
1919
build_preset_specs,
2020
probe_run,
21+
scale_down_method,
2122
suggest_adapters,
2223
suggest_sharding,
2324
verify,
@@ -98,6 +99,7 @@
9899
SuggestShardingParams,
99100
VerifyParams,
100101
PlatformGetInfoParams,
102+
ScaleDownParams,
101103
)
102104

103105
_log = logging.getLogger(__name__)
@@ -123,6 +125,10 @@
123125
BuildPresetSpecsParams,
124126
lambda p, c: build_preset_specs(p, cache=c),
125127
),
128+
"architectures.scale_down": (
129+
ScaleDownParams,
130+
lambda p, c: scale_down_method(p, cache=c),
131+
),
126132
"probe.run": (
127133
ProbeRunParams,
128134
lambda p, c: probe_run(p, cache=c),

cppmega_v4/jsonrpc/methods.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -641,6 +641,46 @@ def build_preset_specs(
641641
return out
642642

643643

644+
# ---------------------------------------------------------------------------
645+
# architectures.scale_down (V8-R02)
646+
# ---------------------------------------------------------------------------
647+
648+
649+
def scale_down_method(
650+
params: "ScaleDownParams", *, cache: LRUCache | None = None,
651+
) -> "ScaleDownResultModel":
652+
"""Binary-search the smallest (H, L) of ``preset`` that fits the
653+
target byte budget. Returns the wire-form payload directly."""
654+
key, hit = _cache_lookup(cache, "architectures.scale_down", params)
655+
if hit is not None:
656+
return hit
657+
658+
from cppmega_v4.architectures.scale_down import scale_down as _scale_down
659+
from cppmega_v4.jsonrpc.schema import (
660+
ScaleDownResultModel, ScaleDownFromCanonical,
661+
)
662+
663+
res = _scale_down(
664+
params.preset, params.target_bytes,
665+
min_hidden=params.min_hidden,
666+
min_layers=params.min_layers,
667+
)
668+
out = ScaleDownResultModel(
669+
hidden_size=res.hidden_size,
670+
num_layers=res.num_layers,
671+
estimated_bytes=res.estimated_bytes,
672+
target_bytes=res.target_bytes,
673+
fits=res.fits,
674+
scaled_down_from=ScaleDownFromCanonical(
675+
hidden_size=res.scaled_down_from[0],
676+
num_layers=res.scaled_down_from[1],
677+
),
678+
specs=res.specs,
679+
)
680+
_cache_store(cache, key, out)
681+
return out
682+
683+
644684
# ---------------------------------------------------------------------------
645685
# probe.run
646686
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)