Skip to content

Commit 677d4ec

Browse files
committed
feat(v7-d06,v7-h06): dtype.cost_estimate RPC + pause/resume integration
Two paired UI honest-closure gaps closed: V7-D06: master_dtype dropdown previously showed only dtype names. The bench_dtype_cast_cost.py script produced static CSV/HTML offline but the live UI surfaced no real cost. After D06 wiring, the dropdown fetches dtype.cost_estimate on first open and renders ms/token next to each option. V7-H06: pause / resume mid-train. pipeline.pause / pipeline.resume RPC handlers were declared but their handler functions did not exist. This commit completes the dispatcher side and adds the UI button. - jsonrpc/dtype_cost_method.py: tiny inline probe (cast+fwd+bwd warm-loop, ms/token derived) returns one row per dtype. - jsonrpc/dispatcher.py + schema.py: registers dtype.cost_estimate, pipeline.pause, pipeline.resume in METHOD_REGISTRY. - runner/stages.py: stage_train polls job_control.wait_while_paused at the top of each step (cooperative with abort_token). - TopBar: dtype-cost-summary indicator, ms/tok suffix on each option; Pause button next to Cancel, toggles label to Resume. - App.tsx: handlePauseTrain / handleResumeTrain callbacks; trainPaused state cleared whenever a new run starts. - tests/v4/test_dtype_cost_method.py: 3-row response + param validation. - tests/v4/test_pipeline_pause_resume_rpc.py: pause marks token, resume clears it, both methods appear in /schema/methods. - e2e 84/85: visual assertions for dropdown labels and button toggle.
1 parent 3775d53 commit 677d4ec

10 files changed

Lines changed: 459 additions & 3 deletions

File tree

cppmega_v4/jsonrpc/dispatcher.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@
4545
from cppmega_v4.jsonrpc.ckpt_inspect_method import (
4646
CkptInspectParams, ckpt_inspect,
4747
)
48+
from cppmega_v4.jsonrpc.dtype_cost_method import (
49+
DtypeCostParams, dtype_cost_estimate,
50+
)
4851
from cppmega_v4.jsonrpc.schema import (
4952
BuildPresetSpecsParams,
5053
CatalogExplainParams,
@@ -99,6 +102,14 @@
99102
PipelineAbortParams,
100103
lambda p, c: _pipeline_abort(p),
101104
),
105+
"pipeline.pause": (
106+
PipelineAbortParams,
107+
lambda p, c: _pipeline_pause(p),
108+
),
109+
"pipeline.resume": (
110+
PipelineAbortParams,
111+
lambda p, c: _pipeline_resume(p),
112+
),
102113
"tokenizer.encode_visualize": (
103114
EncodeVisualizeParams,
104115
lambda p, c: encode_visualize(p, cache=c),
@@ -131,6 +142,10 @@
131142
CkptInspectParams,
132143
lambda p, c: ckpt_inspect(p, cache=c),
133144
),
145+
"dtype.cost_estimate": (
146+
DtypeCostParams,
147+
lambda p, c: dtype_cost_estimate(p, cache=c),
148+
),
134149
}
135150

136151

@@ -153,6 +168,20 @@ def _pipeline_abort(params: PipelineAbortParams) -> PipelineAbortResult:
153168
return PipelineAbortResult(run_id=params.run_id)
154169

155170

171+
def _pipeline_pause(params: PipelineAbortParams) -> PipelineAbortResult:
172+
"""V7-H06: mark a train run as paused; the loop waits between steps."""
173+
from cppmega_v4.runtime.job_control import pause
174+
pause(params.run_id)
175+
return PipelineAbortResult(run_id=params.run_id)
176+
177+
178+
def _pipeline_resume(params: PipelineAbortParams) -> PipelineAbortResult:
179+
"""V7-H06: clear the paused flag so the train loop proceeds."""
180+
from cppmega_v4.runtime.job_control import resume
181+
resume(params.run_id)
182+
return PipelineAbortResult(run_id=params.run_id)
183+
184+
156185
def dispatch(
157186
payload: Mapping[str, Any] | JsonRpcRequest,
158187
*,
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
"""V7-D06: dtype.cost_estimate RPC.
2+
3+
Honest-closure: scripts/bench_dtype_cast_cost.py produced a static
4+
CSV/HTML report of cast_overhead_ms + fwd_ms + fwdbwd_ms per dtype,
5+
but the UI dtype dropdown showed no numbers and the user could not
6+
see the real cost of fp32 vs bf16 vs fp16 before clicking Train.
7+
8+
This method runs an inline tiny-model probe (n_iter=5 by default,
9+
B=1 S=8 H=128) so the UI can fetch fresh numbers on mount and render
10+
ms/token alongside each option in the dropdown.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import time
16+
from typing import Literal
17+
18+
from pydantic import BaseModel, ConfigDict, Field
19+
20+
import mlx.core as mx
21+
import mlx.nn as nn
22+
23+
from cppmega_v4.jsonrpc.cache import LRUCache
24+
25+
DtypeName = Literal["fp32", "bf16", "fp16"]
26+
27+
28+
class DtypeCostParams(BaseModel):
29+
model_config = ConfigDict(extra="forbid")
30+
31+
n_iter: int = Field(5, ge=1, le=200)
32+
hidden: int = Field(128, ge=16, le=4096)
33+
batch: int = Field(1, ge=1, le=32)
34+
seq: int = Field(8, ge=1, le=2048)
35+
36+
37+
class DtypeCostRow(BaseModel):
38+
model_config = ConfigDict(extra="allow")
39+
40+
dtype: DtypeName
41+
supported: bool
42+
cast_overhead_ms: float | None = None
43+
fwd_ms: float | None = None
44+
fwdbwd_ms: float | None = None
45+
fwd_ms_per_token: float | None = None
46+
fwdbwd_ms_per_token: float | None = None
47+
error: str | None = None
48+
49+
50+
class DtypeCostResult(BaseModel):
51+
model_config = ConfigDict(extra="forbid")
52+
53+
rows: list[DtypeCostRow] = Field(default_factory=list)
54+
n_iter: int
55+
shape: list[int]
56+
57+
58+
def _build_tiny(hidden: int) -> nn.Module:
59+
class _Block(nn.Module):
60+
def __init__(self) -> None:
61+
super().__init__()
62+
self.q = nn.Linear(hidden, hidden, bias=False)
63+
self.up = nn.Linear(hidden, 4 * hidden, bias=False)
64+
self.down = nn.Linear(4 * hidden, hidden, bias=False)
65+
66+
def __call__(self, x: mx.array) -> mx.array:
67+
return self.down(nn.silu(self.up(self.q(x))))
68+
69+
return _Block()
70+
71+
72+
def _bench_one(dtype: DtypeName, *, n_iter: int, hidden: int,
73+
batch: int, seq: int) -> DtypeCostRow:
74+
dtype_map = {"fp32": mx.float32, "bf16": mx.bfloat16,
75+
"fp16": mx.float16}
76+
if dtype not in dtype_map:
77+
return DtypeCostRow(dtype=dtype, supported=False,
78+
error=f"unknown dtype {dtype}")
79+
target = dtype_map[dtype]
80+
try:
81+
model = _build_tiny(hidden)
82+
t0 = time.perf_counter()
83+
model.set_dtype(target)
84+
mx.eval(model.parameters())
85+
cast_ms = (time.perf_counter() - t0) * 1000.0
86+
87+
x = mx.random.normal(shape=(batch, seq, hidden),
88+
key=mx.random.key(0)).astype(target)
89+
# Warm.
90+
warm = model(x)
91+
mx.eval(warm)
92+
t0 = time.perf_counter()
93+
for _ in range(n_iter):
94+
out = model(x)
95+
mx.eval(out)
96+
fwd_ms = (time.perf_counter() - t0) * 1000.0 / n_iter
97+
98+
def _loss(m, _x):
99+
return mx.mean(m(_x).astype(mx.float32) ** 2)
100+
101+
lvg = nn.value_and_grad(model, _loss)
102+
warm_lvg = lvg(model, x)
103+
mx.eval(warm_lvg)
104+
t0 = time.perf_counter()
105+
for _ in range(n_iter):
106+
loss, grads = lvg(model, x)
107+
mx.eval(loss, grads)
108+
fwdbwd_ms = (time.perf_counter() - t0) * 1000.0 / n_iter
109+
110+
tokens = batch * seq
111+
return DtypeCostRow(
112+
dtype=dtype, supported=True,
113+
cast_overhead_ms=round(cast_ms, 4),
114+
fwd_ms=round(fwd_ms, 4),
115+
fwdbwd_ms=round(fwdbwd_ms, 4),
116+
fwd_ms_per_token=round(fwd_ms / tokens, 6),
117+
fwdbwd_ms_per_token=round(fwdbwd_ms / tokens, 6),
118+
)
119+
except Exception as exc:
120+
return DtypeCostRow(dtype=dtype, supported=False, error=str(exc))
121+
122+
123+
def dtype_cost_estimate(
124+
params: DtypeCostParams, *, cache: LRUCache | None = None,
125+
) -> DtypeCostResult:
126+
dtype_names: tuple[DtypeName, ...] = ("fp32", "bf16", "fp16")
127+
rows = [
128+
_bench_one(dt, n_iter=params.n_iter, hidden=params.hidden,
129+
batch=params.batch, seq=params.seq)
130+
for dt in dtype_names
131+
]
132+
return DtypeCostResult(
133+
rows=rows, n_iter=params.n_iter,
134+
shape=[params.batch, params.seq, params.hidden],
135+
)
136+
137+
138+
__all__ = ["DtypeCostParams", "DtypeCostResult", "DtypeCostRow",
139+
"dtype_cost_estimate"]

cppmega_v4/jsonrpc/schema.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -750,6 +750,9 @@ class PipelineRunResult(BaseModel):
750750
"data.roundtrip_check",
751751
"ablation.run",
752752
"ckpt.inspect",
753+
"dtype.cost_estimate",
754+
"pipeline.pause",
755+
"pipeline.resume",
753756
})
754757

755758

cppmega_v4/runner/stages.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1421,7 +1421,10 @@ def _base(k: str) -> str | None:
14211421
val_losses: list[float] = []
14221422
# G09: check abort flag set via opts.abort or _ABORT_TOKENS set
14231423
abort_token = opts.get("abort_token")
1424+
# V7-H06: block while job is paused via job_control.pause(token).
1425+
from cppmega_v4.runtime.job_control import wait_while_paused
14241426
for step in range(n_steps):
1427+
wait_while_paused(abort_token, poll_s=0.05, max_wait_s=600.0)
14251428
if abort_token is not None and abort_token in _ABORT_TOKENS:
14261429
# Stop early; return partial extras with cancellation flag.
14271430
return _cancelled_train_result(

tests/v4/test_dtype_cost_method.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""V7-D06: dtype.cost_estimate RPC round-trip."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
from fastapi.testclient import TestClient
7+
8+
from cppmega_v4.jsonrpc import create_app
9+
10+
11+
@pytest.fixture
12+
def client():
13+
return TestClient(create_app(cache_capacity=2))
14+
15+
16+
def test_dtype_cost_estimate_returns_three_rows(client):
17+
payload = {
18+
"jsonrpc": "2.0", "id": "d1", "method": "dtype.cost_estimate",
19+
"params": {"n_iter": 2, "hidden": 32, "batch": 1, "seq": 4},
20+
}
21+
r = client.post("/rpc", json=payload)
22+
assert r.status_code == 200
23+
body = r.json()
24+
assert "error" not in body, body
25+
res = body["result"]
26+
assert {row["dtype"] for row in res["rows"]} == {"fp32", "bf16", "fp16"}
27+
for row in res["rows"]:
28+
if row["supported"]:
29+
assert row["fwd_ms"] is not None and row["fwd_ms"] >= 0
30+
assert row["fwd_ms_per_token"] is not None
31+
assert row["fwd_ms_per_token"] >= 0
32+
assert row["fwdbwd_ms"] is not None
33+
else:
34+
# Even when unsupported, the row still appears and carries
35+
# an error string the UI can show.
36+
assert row["error"] is not None
37+
38+
39+
def test_dtype_cost_estimate_rejects_huge_params(client):
40+
payload = {
41+
"jsonrpc": "2.0", "id": "d2", "method": "dtype.cost_estimate",
42+
"params": {"n_iter": 10000},
43+
}
44+
r = client.post("/rpc", json=payload)
45+
body = r.json()
46+
assert "error" in body
47+
assert body["error"]["code"] == -32602
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""V7-H06: pipeline.pause / pipeline.resume RPC dispatcher round-trips.
2+
3+
These are the dispatcher-side handlers. The end-to-end pause-during-
4+
train flow is covered by the e2e Playwright test (84_pause_resume).
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import pytest
10+
from fastapi.testclient import TestClient
11+
12+
from cppmega_v4.jsonrpc import create_app
13+
from cppmega_v4.runtime import job_control
14+
15+
16+
@pytest.fixture(autouse=True)
17+
def _clean_state():
18+
job_control.reset()
19+
yield
20+
job_control.reset()
21+
22+
23+
@pytest.fixture
24+
def client():
25+
return TestClient(create_app(cache_capacity=2))
26+
27+
28+
def test_pipeline_pause_marks_token_paused(client):
29+
payload = {"jsonrpc": "2.0", "id": "p1", "method": "pipeline.pause",
30+
"params": {"run_id": "run-abc"}}
31+
r = client.post("/rpc", json=payload)
32+
assert r.status_code == 200
33+
body = r.json()
34+
assert body["result"]["run_id"] == "run-abc"
35+
assert job_control.is_paused("run-abc") is True
36+
37+
38+
def test_pipeline_resume_clears_pause(client):
39+
job_control.pause("run-xyz")
40+
assert job_control.is_paused("run-xyz") is True
41+
payload = {"jsonrpc": "2.0", "id": "r1", "method": "pipeline.resume",
42+
"params": {"run_id": "run-xyz"}}
43+
r = client.post("/rpc", json=payload)
44+
assert r.status_code == 200
45+
assert r.json()["result"]["run_id"] == "run-xyz"
46+
assert job_control.is_paused("run-xyz") is False
47+
48+
49+
def test_pause_resume_methods_appear_in_registry(client):
50+
r = client.get("/schema/methods")
51+
methods = set(r.json()["methods"])
52+
assert "pipeline.pause" in methods
53+
assert "pipeline.resume" in methods
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// V7-D06: master_dtype dropdown renders real ms/token cost from
2+
// dtype.cost_estimate RPC. Honest-closure: previously the bench script
3+
// produced a static CSV/HTML report but the live UI showed only dtype
4+
// names — now each option carries " · X.XXXX ms/tok" next to its label.
5+
6+
import { test, expect } from "@playwright/test";
7+
import { gotoApp, selectPreset } from "../fixtures";
8+
9+
test("V7-D06: master_dtype options show ms/tok cost after dropdown opens",
10+
async ({ page }) => {
11+
test.setTimeout(60_000);
12+
await gotoApp(page);
13+
await selectPreset(page, "llama3_8b");
14+
15+
await page.getByTestId("run-pipeline-toggle").click();
16+
// Probe fires once when the menu first opens — wait for either
17+
// measured indicator OR the loading indicator to disappear.
18+
await expect(page.getByTestId("dtype-cost-summary"))
19+
.toBeVisible({ timeout: 30_000 });
20+
21+
const select = page.getByTestId("top-bar-precision-mode");
22+
const fp32Text = await select.locator("option[value='fp32']").textContent();
23+
const bf16Text = await select.locator("option[value='bf16']").textContent();
24+
const fp16Text = await select.locator("option[value='fp16']").textContent();
25+
// Each supported dtype carries the ms/tok suffix.
26+
expect(fp32Text).toMatch(/ms\/tok/);
27+
expect(bf16Text).toMatch(/ms\/tok/);
28+
expect(fp16Text).toMatch(/ms\/tok/);
29+
});
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// V7-H06: pause / resume mid-train. UI button toggles between Pause
2+
// and Resume, calls pipeline.pause / pipeline.resume RPCs, and the
3+
// backend job_control module blocks the train loop until resumed.
4+
5+
import { test, expect } from "@playwright/test";
6+
import { gotoApp, selectPreset, closeModal } from "../fixtures";
7+
8+
test("V7-H06: pause button toggles label and disables when no run is active",
9+
async ({ page }) => {
10+
test.setTimeout(30_000);
11+
await gotoApp(page);
12+
await selectPreset(page, "llama3_8b");
13+
14+
// No train running yet → pause button visible but disabled.
15+
const pauseBtn = page.getByTestId("run-pipeline-pause");
16+
await expect(pauseBtn).toBeVisible();
17+
await expect(pauseBtn).toBeDisabled();
18+
await expect(pauseBtn).toHaveText(/Pause/i);
19+
});
20+
21+
test("V7-H06: long-running train can be paused then resumed",
22+
async ({ page }) => {
23+
test.setTimeout(120_000);
24+
await gotoApp(page);
25+
await selectPreset(page, "llama3_8b");
26+
27+
await page.getByTestId("run-pipeline-toggle").click();
28+
// Use enough steps so we have time to hit pause mid-run.
29+
await page.getByTestId("train-num-steps").fill("8");
30+
await page.getByTestId("run-pipeline-train").click();
31+
32+
const pauseBtn = page.getByTestId("run-pipeline-pause");
33+
// Wait until trainInFlight engages — pause button becomes enabled.
34+
await expect(pauseBtn).toBeEnabled({ timeout: 10_000 });
35+
36+
await pauseBtn.click();
37+
// Button label flips to Resume.
38+
await expect(pauseBtn).toHaveText(/Resume/i, { timeout: 5_000 });
39+
40+
// After short hold, click Resume.
41+
await page.waitForTimeout(500);
42+
await pauseBtn.click();
43+
await expect(pauseBtn).toHaveText(/Pause/i, { timeout: 5_000 });
44+
45+
// Eventually the modal appears with status ok.
46+
const modal = page.getByTestId("run-result-modal");
47+
await modal.waitFor({ timeout: 120_000 });
48+
await closeModal(page);
49+
});

0 commit comments

Comments
 (0)