Skip to content

Commit 5b877f4

Browse files
committed
feat(v8-r04): architectures.auto_fit + UI Auto-fit button
Closes cppmega-mlx-yz6n.4. New RPC architectures.auto_fit(preset, host_info?) probes the platform (via platform.get_info when host_info is null), picks total_hbm_bytes × headroom as the byte budget, runs scale_down to land a fitting (H, L), then calls suggest_sharding with a minimal default cross_entropy + adamw spec to get ranked sharding proposals. Bundle returned: scaled + sharding + fits + reason. UI: GalleryScaleDownSlider grows an "Auto-fit to my devbox" button that fires the RPC and renders a result chip with the chosen topology + peak/budget reason string. AC: llama3_8b on gb10_quarter fits the canonical 8B (51.67 GB / 137 GB) end-to-end through scale_down + suggest_sharding. Tests: 6/6 new pytest (fit/no-fit, unknown topology, platform-probe fallback, dispatch e2e, headroom monotonicity), 1/1 new vitest case (auto-fit button click → banner). Regression: 899 pytest + 459 vitest.
1 parent d4322f8 commit 5b877f4

6 files changed

Lines changed: 368 additions & 0 deletions

File tree

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
"""V8-R04: ``architectures.auto_fit`` — given a preset, pick the best
2+
``(hidden_size, num_layers)`` AND a sharding proposal for the detected
3+
(or supplied) devbox in one shot.
4+
5+
Strategy:
6+
1. Resolve the target topology — either ``host_info.topology`` from
7+
the caller, or pick the first ``available_topologies`` entry from
8+
``platform.get_info``.
9+
2. Compute ``target_bytes = total_hbm × headroom`` and run
10+
:func:`architectures.scale_down.scale_down` to land a model that
11+
fits the device.
12+
3. Call :func:`suggest_sharding` against the scaled spec with a
13+
minimal default ``loss=cross_entropy`` + ``optim=adamw`` so the
14+
planner has enough to produce ranked proposals.
15+
4. Return the bundle: scaled / sharding / fits / reason.
16+
"""
17+
18+
from __future__ import annotations
19+
20+
from typing import Any
21+
22+
from pydantic import BaseModel, ConfigDict
23+
24+
from cppmega_v4.architectures.scale_down import scale_down as _scale_down
25+
from cppmega_v4.jsonrpc.cache import LRUCache
26+
from cppmega_v4.jsonrpc.methods import (
27+
_cache_lookup, _cache_store, suggest_sharding,
28+
)
29+
from cppmega_v4.jsonrpc.schema import (
30+
ScaleDownFromCanonical, ScaleDownResultModel,
31+
SuggestShardingParams, SuggestShardingResult,
32+
)
33+
from cppmega_v4.parallelism import topology as _topo
34+
35+
36+
__all__ = [
37+
"AutoFitParams",
38+
"AutoFitHostInfo",
39+
"AutoFitResult",
40+
"auto_fit",
41+
"TOPOLOGY_BUILDERS",
42+
]
43+
44+
45+
# Same table as memory_matrix_method but kept local to avoid a cycle.
46+
TOPOLOGY_BUILDERS: dict[str, Any] = {
47+
"h100_8x": lambda: _topo.h100_8x(),
48+
"h200_8x": lambda: _topo.h200_8x(),
49+
"a100_8x": lambda: _topo.a100_8x(),
50+
"b100_8x": lambda: _topo.b100_8x(),
51+
"gb10_quarter": lambda: _topo.gb10_quarter(),
52+
"tpu_v6e_8": lambda: _topo.tpu_v6e_8(),
53+
"tpu_v5p_4": lambda: _topo.tpu_v5p_4(),
54+
"m3_ultra_solo": lambda: _topo.m3_ultra_solo(),
55+
}
56+
57+
58+
class AutoFitHostInfo(BaseModel):
59+
"""When supplied, overrides the server-side platform probe."""
60+
61+
model_config = ConfigDict(extra="forbid")
62+
63+
topology: str
64+
headroom: float = 0.9
65+
66+
67+
class AutoFitParams(BaseModel):
68+
"""Input — preset name + optional host overrides."""
69+
70+
model_config = ConfigDict(extra="forbid")
71+
72+
preset: str
73+
host_info: AutoFitHostInfo | None = None
74+
75+
76+
class AutoFitResult(BaseModel):
77+
"""Output — scale-down result + sharding proposals + fits verdict."""
78+
79+
model_config = ConfigDict(extra="forbid")
80+
81+
scaled: ScaleDownResultModel
82+
sharding: SuggestShardingResult
83+
fits: bool
84+
reason: str
85+
topology: str
86+
headroom: float
87+
88+
89+
def _resolve_topology(params: AutoFitParams) -> tuple[str, float]:
90+
"""Pick (topology_name, headroom) from host_info or platform probe."""
91+
if params.host_info is not None:
92+
if params.host_info.topology not in TOPOLOGY_BUILDERS:
93+
raise ValueError(
94+
f"unknown topology {params.host_info.topology!r}; "
95+
f"choose from {sorted(TOPOLOGY_BUILDERS)}")
96+
return params.host_info.topology, params.host_info.headroom
97+
from cppmega_v4.runtime.platform_probe import probe_platform
98+
info = probe_platform()
99+
available = info.get("available_topologies") or []
100+
for t in available:
101+
if t in TOPOLOGY_BUILDERS:
102+
return t, 0.9
103+
return "m3_ultra_solo", 0.9 # final fallback
104+
105+
106+
def auto_fit(
107+
params: AutoFitParams, *, cache: LRUCache | None = None,
108+
) -> AutoFitResult:
109+
"""Chain scale_down + suggest_sharding for the detected/given devbox."""
110+
key, hit = _cache_lookup(cache, "architectures.auto_fit", params)
111+
if hit is not None:
112+
return hit
113+
114+
topo_name, headroom = _resolve_topology(params)
115+
topo = TOPOLOGY_BUILDERS[topo_name]()
116+
total_hbm = topo.total_hbm_bytes
117+
target_bytes = int(total_hbm * headroom)
118+
scaled = _scale_down(params.preset, target_bytes)
119+
120+
# Build minimal sharding query payload — use the scaled specs +
121+
# cross_entropy + adamw defaults. We feed the scaled hidden_size
122+
# so the suggester sees the right model size, and head_outputs to
123+
# the last brick so verify_build_spec accepts the request.
124+
graph_nodes = []
125+
graph_edges = []
126+
prev_name: str | None = None
127+
for spec in scaled.specs:
128+
if "parallel" in spec:
129+
# Each branch becomes a leaf for the suggester.
130+
for leaf in spec["parallel"]:
131+
name = leaf.get("name") or leaf.get("kind")
132+
graph_nodes.append({
133+
"id": name, "kind": leaf["kind"],
134+
"params": leaf.get("params", {}),
135+
})
136+
if prev_name is not None:
137+
graph_edges.append({"src": prev_name, "dst": name})
138+
prev_name = name
139+
continue
140+
name = spec.get("name") or spec.get("kind")
141+
graph_nodes.append({
142+
"id": name, "kind": spec["kind"],
143+
"params": spec.get("params", {}),
144+
})
145+
if prev_name is not None:
146+
graph_edges.append({"src": prev_name, "dst": name})
147+
prev_name = name
148+
149+
last = graph_nodes[-1]["id"] if graph_nodes else "out"
150+
sharding_params = SuggestShardingParams.model_validate({
151+
"graph": {"nodes": graph_nodes, "edges": graph_edges},
152+
"dim_env": {"H": scaled.hidden_size, "B": 1, "S": 256},
153+
"loss": {"kind": "cross_entropy", "head_outputs": [last]},
154+
"optim": {"kind": "adamw", "groups": [
155+
{"matcher": "all", "lr": 3e-4, "weight_decay": 0.01,
156+
"betas": [0.9, 0.95]},
157+
], "gradient_clip_norm": 1.0, "mixed_precision": True},
158+
"topology": {"factory": topo_name, "kwargs": {}},
159+
})
160+
sharding = suggest_sharding(sharding_params, cache=cache)
161+
162+
best_proposal = (
163+
sharding.proposals[0] if sharding.proposals else None)
164+
fits = bool(scaled.fits and (
165+
best_proposal is None or best_proposal.fits))
166+
axis_desc = (
167+
",".join(f"{a.axis_name}×{a.degree}"
168+
for a in best_proposal.sharding.axis_assignments)
169+
if best_proposal else "<no proposal>")
170+
reason = (
171+
f"hidden={scaled.hidden_size}, layers={scaled.num_layers}, "
172+
f"axis={axis_desc}, peak={scaled.estimated_bytes / 1e9:.2f} GB / "
173+
f"{total_hbm / 1e9:.0f} GB")
174+
out = AutoFitResult(
175+
scaled=ScaleDownResultModel(
176+
hidden_size=scaled.hidden_size,
177+
num_layers=scaled.num_layers,
178+
estimated_bytes=scaled.estimated_bytes,
179+
target_bytes=scaled.target_bytes,
180+
fits=scaled.fits,
181+
scaled_down_from=ScaleDownFromCanonical(
182+
hidden_size=scaled.scaled_down_from[0],
183+
num_layers=scaled.scaled_down_from[1],
184+
),
185+
specs=scaled.specs,
186+
),
187+
sharding=sharding,
188+
fits=fits,
189+
reason=reason,
190+
topology=topo_name,
191+
headroom=headroom,
192+
)
193+
_cache_store(cache, key, out)
194+
return out

cppmega_v4/jsonrpc/dispatcher.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@
7979
MemoryMatrixParams,
8080
memory_matrix,
8181
)
82+
from cppmega_v4.jsonrpc.auto_fit_method import (
83+
AutoFitParams,
84+
auto_fit,
85+
)
8286
from cppmega_v4.jsonrpc.tokenizer_roundtrip_text_method import (
8387
TokenizerRoundtripTextParams,
8488
roundtrip_text,
@@ -137,6 +141,10 @@
137141
MemoryMatrixParams,
138142
lambda p, c: memory_matrix(p, cache=c),
139143
),
144+
"architectures.auto_fit": (
145+
AutoFitParams,
146+
lambda p, c: auto_fit(p, cache=c),
147+
),
140148
"probe.run": (
141149
ProbeRunParams,
142150
lambda p, c: probe_run(p, cache=c),

cppmega_v4/jsonrpc/schema.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -809,6 +809,7 @@ class PipelineRunResult(BaseModel):
809809
"platform.get_info",
810810
"architectures.scale_down",
811811
"memory.matrix",
812+
"architectures.auto_fit",
812813
})
813814

814815

tests/v4/test_auto_fit.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""V8-R04 unit tests: architectures.auto_fit RPC."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
7+
from cppmega_v4.jsonrpc.auto_fit_method import (
8+
AutoFitHostInfo, AutoFitParams, auto_fit,
9+
)
10+
from cppmega_v4.jsonrpc.dispatcher import dispatch
11+
from cppmega_v4.jsonrpc.schema import JsonRpcRequest
12+
13+
14+
def test_llama3_8b_on_gb10_fits_full_size():
15+
"""The canonical 8B preset fits on a single GB10 (137 GB unified)."""
16+
r = auto_fit(AutoFitParams(
17+
preset="llama3_8b",
18+
host_info=AutoFitHostInfo(topology="gb10_quarter")))
19+
assert r.topology == "gb10_quarter"
20+
assert r.headroom == 0.9
21+
assert r.fits is True
22+
assert r.scaled.hidden_size == 4096 # canonical (no scale-down needed)
23+
assert r.scaled.num_layers == 32
24+
assert r.scaled.estimated_bytes <= int(137e9 * 0.9)
25+
assert len(r.sharding.proposals) >= 1
26+
assert "hidden=4096" in r.reason
27+
assert "GB" in r.reason
28+
29+
30+
def test_huge_preset_on_tiny_topology_still_returns():
31+
"""When nothing fits, auto_fit still returns a result with fits=False
32+
instead of raising."""
33+
r = auto_fit(AutoFitParams(
34+
preset="qwen3_235b_a22b",
35+
host_info=AutoFitHostInfo(topology="gb10_quarter", headroom=0.5)))
36+
# The full 235B doesn't fit even on a GB10 at H=8192 — but
37+
# scale_down will pick the largest (H, L) ≤ target. If no cell on
38+
# the search grid fits, fits=False.
39+
assert r.topology == "gb10_quarter"
40+
# Just check the contract is well-formed.
41+
assert r.scaled.hidden_size >= 64
42+
assert r.scaled.num_layers >= 1
43+
assert isinstance(r.fits, bool)
44+
45+
46+
def test_unknown_topology_rejected():
47+
with pytest.raises(ValueError, match="unknown topology"):
48+
auto_fit(AutoFitParams(
49+
preset="llama3_8b",
50+
host_info=AutoFitHostInfo(topology="nope")))
51+
52+
53+
def test_host_info_omitted_uses_platform_probe():
54+
"""Without host_info, auto_fit picks a topology from platform.get_info."""
55+
r = auto_fit(AutoFitParams(preset="llama3_8b"))
56+
assert r.topology in {
57+
"m3_ultra_solo", "gb10_quarter",
58+
"h100_8x", "h200_8x", "a100_8x", "b100_8x",
59+
"tpu_v6e_8", "tpu_v5p_4"}
60+
61+
62+
def test_dispatch_end_to_end():
63+
req = JsonRpcRequest(
64+
jsonrpc="2.0", id="t-r04",
65+
method="architectures.auto_fit",
66+
params={"preset": "qwen3_dense_4b",
67+
"host_info": {"topology": "h100_8x"}},
68+
)
69+
resp = dispatch(req)
70+
assert resp.error is None, resp.error
71+
r = resp.result
72+
assert r["topology"] == "h100_8x"
73+
assert r["fits"] is True
74+
assert r["scaled"]["hidden_size"] >= 64
75+
assert isinstance(r["sharding"]["proposals"], list)
76+
assert "GB" in r["reason"]
77+
78+
79+
def test_headroom_propagates_to_target_bytes():
80+
"""Lower headroom should produce a smaller target budget."""
81+
r_high = auto_fit(AutoFitParams(
82+
preset="llama3_8b",
83+
host_info=AutoFitHostInfo(topology="gb10_quarter", headroom=0.9)))
84+
r_low = auto_fit(AutoFitParams(
85+
preset="llama3_8b",
86+
host_info=AutoFitHostInfo(topology="gb10_quarter", headroom=0.3)))
87+
# Lower headroom can never pick a *larger* model.
88+
assert (r_low.scaled.hidden_size * r_low.scaled.num_layers
89+
<= r_high.scaled.hidden_size * r_high.scaled.num_layers)

vbgui/src/components/GalleryScaleDownSlider.tsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,14 @@ function fmtBytes(n: number): string {
5252
return `${n} B`;
5353
}
5454

55+
interface AutoFitResult {
56+
scaled: ScaleDownPreview;
57+
fits: boolean;
58+
reason: string;
59+
topology: string;
60+
headroom: number;
61+
}
62+
5563
export function GalleryScaleDownSlider({
5664
presets, rpc, onApply, initialBytes = ONE_GB, initialPreset,
5765
}: GalleryScaleDownSliderProps): JSX.Element {
@@ -61,6 +69,9 @@ export function GalleryScaleDownSlider({
6169
const [preview, setPreview] = useState<ScaleDownPreview | null>(null);
6270
const [err, setErr] = useState<string | null>(null);
6371
const [loading, setLoading] = useState(false);
72+
const [autoFitResult, setAutoFitResult] =
73+
useState<AutoFitResult | null>(null);
74+
const [autoFitBusy, setAutoFitBusy] = useState(false);
6475
// Per-request token so stale responses are dropped.
6576
const reqIdRef = useRef(0);
6677

@@ -90,6 +101,23 @@ export function GalleryScaleDownSlider({
90101
return () => clearTimeout(t);
91102
}, [preset, bytes, fetchPreview]);
92103

104+
const runAutoFit = useCallback(async () => {
105+
if (!preset) return;
106+
setAutoFitBusy(true);
107+
setErr(null);
108+
try {
109+
const r = await rpc.call<AutoFitResult>(
110+
"architectures.auto_fit", { preset });
111+
setAutoFitResult(r);
112+
// Mirror as the preview block so the user sees identical state.
113+
setPreview(r.scaled);
114+
} catch (e) {
115+
setErr(e instanceof Error ? e.message : String(e));
116+
} finally {
117+
setAutoFitBusy(false);
118+
}
119+
}, [rpc, preset]);
120+
93121
return (
94122
<div data-testid="gallery-scaledown" style={{ padding: 8,
95123
border: "1px solid #e5e7eb", borderRadius: 6, marginTop: 8,
@@ -120,6 +148,21 @@ export function GalleryScaleDownSlider({
120148
{loading && <span data-testid="gallery-scaledown-loading"></span>}
121149
{err && <span data-testid="gallery-scaledown-error"
122150
style={{ color: "#b91c1c" }}>{err}</span>}
151+
<button
152+
data-testid="gallery-auto-fit"
153+
onClick={() => { void runAutoFit(); }}
154+
disabled={autoFitBusy || !preset}
155+
style={{ alignSelf: "flex-start", padding: "2px 8px" }}>
156+
{autoFitBusy ? "auto-fitting…" : "Auto-fit to my devbox"}
157+
</button>
158+
{autoFitResult && (
159+
<div data-testid="gallery-auto-fit-result"
160+
style={{ fontSize: 11, color: "#374151",
161+
background: "#eff6ff", padding: 6,
162+
borderRadius: 4 }}>
163+
<strong>{autoFitResult.topology}</strong> · {autoFitResult.reason}
164+
</div>
165+
)}
123166
{preview && (
124167
<div style={{ fontSize: 11, color: "#374151",
125168
display: "flex", flexDirection: "column", gap: 2 }}>

0 commit comments

Comments
 (0)