Skip to content

Commit 19cb7a8

Browse files
committed
feat(e-audit-05): cppmega_mlx.cli.smoke_zero1 entrypoint
V7-Q05 (E-AUDIT-05, cppmega-mlx-bfbi) — Lane 8 audit gap closure. Implements the ZeRO-1 smoke CLI documented as "not yet implemented" in docs/distributed_zero1_smoke_procedure.md:170. Thin wrapper around scripts/bench_zero1_loopback.py that builds the mlx.launch command: python -m cppmega_mlx.cli.smoke_zero1 \ --hosts 127.0.0.1,127.0.0.1 \ --num-steps 20 \ --out bench/baselines/zero1_loopback_2proc_m4.json Flags: - --hosts CSV (default 127.0.0.1,127.0.0.1 loopback) - --world-size N (derived from hosts when 0) - --num-steps N (default 20) - --lr F (default 1e-4) - --out PATH (default bench/baselines/zero1_smoke.json) - --simulate (in-process fallback, no mlx.launch) - --mlx-launch PATH (override mlx.launch binary) - --dry-run (print would-be argv on stderr, exit 0) Backend bench script unchanged — CLI just orchestrates mlx.launch with the right -n / --hosts arguments and forwards --steps/--lr/--out to the existing bench main(). Tests (8/8 PASS, tests/v4/test_smoke_zero1_cli.py): - _resolve_world_size from CSV + explicit override - _build_launch_argv argv composition - _build_simulate_argv flag insertion - --dry-run loopback + simulate paths - missing mlx.launch on PATH -> exit 64 (EX_USAGE) - DEFAULT_OUT / DEFAULT_SIMULATE_OUT path defaults Doc updates: - docs/distributed_zero1_smoke_procedure.md: replaced "not yet implemented" placeholder with shipping-status note. Multi-Mac receipt (peer-48) remains BLOCKED on external hardware per § "Step 3" — this CLI unblocks the loopback receipt today and will produce a real multi-Mac receipt as soon as peer-48 SSH is up. Closes Lane 8 P1 audit gap from docs/UI-TO-TRAIN-AUDIT-2026-05-23.md.
1 parent 6adff34 commit 19cb7a8

4 files changed

Lines changed: 280 additions & 3 deletions

File tree

cppmega_mlx/cli/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"""cppmega_mlx CLI entrypoints.
2+
3+
Each module exposes a ``main()`` function callable from
4+
``python -m cppmega_mlx.cli.<name>`` for end-user orchestration of
5+
single-Mac, loopback, and multi-mac smoke receipts.
6+
7+
Currently shipping:
8+
- smoke_zero1 — ZeRO-1 distributed-wrapper receipt (loopback or
9+
multi-host) via ``mlx.launch``. V7-Q05 closure of
10+
``docs/distributed_zero1_smoke_procedure.md`` "not yet implemented".
11+
"""

cppmega_mlx/cli/smoke_zero1.py

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
"""V7-Q05: cppmega_mlx.cli.smoke_zero1 — ZeRO-1 distributed receipt.
2+
3+
Thin entrypoint that wraps scripts/bench_zero1_loopback.py under
4+
``mlx.launch -n W --hosts H1,H2,...`` so a single command produces the
5+
loopback / multi-mac receipt JSON. Closes
6+
``docs/distributed_zero1_smoke_procedure.md`` Step 1 "not yet
7+
implemented" placeholder.
8+
9+
Usage::
10+
11+
# 2-process loopback on the same Mac (canonical receipt)
12+
python -m cppmega_mlx.cli.smoke_zero1 \\
13+
--hosts 127.0.0.1,127.0.0.1 \\
14+
--out bench/baselines/zero1_loopback_2proc_m4.json
15+
16+
# Multi-Mac (requires mlx.launch ssh access to both hosts)
17+
python -m cppmega_mlx.cli.smoke_zero1 \\
18+
--hosts dev-128.local,peer-48.local \\
19+
--num-steps 20
20+
21+
# In-process simulation fallback (no mlx.launch needed)
22+
python -m cppmega_mlx.cli.smoke_zero1 --simulate \\
23+
--num-steps 8
24+
25+
# Show the launch command without running it
26+
python -m cppmega_mlx.cli.smoke_zero1 --hosts 127.0.0.1,127.0.0.1 \\
27+
--dry-run
28+
"""
29+
30+
from __future__ import annotations
31+
32+
import argparse
33+
import shutil
34+
import subprocess
35+
import sys
36+
from pathlib import Path
37+
38+
# Sit at <repo>/cppmega_mlx/cli/smoke_zero1.py — two parents up = repo root.
39+
REPO_ROOT = Path(__file__).resolve().parents[2]
40+
DEFAULT_OUT = REPO_ROOT / "bench" / "baselines" / "zero1_smoke.json"
41+
DEFAULT_SIMULATE_OUT = (
42+
REPO_ROOT / "bench" / "baselines" / "zero1_simulated_2proc_m4.json"
43+
)
44+
BENCH_SCRIPT = REPO_ROOT / "scripts" / "bench_zero1_loopback.py"
45+
46+
47+
def _resolve_world_size(hosts_csv: str, override: int) -> int:
48+
"""Derive world size from the hosts CSV unless an override is given."""
49+
if override and override > 0:
50+
return int(override)
51+
return len([h for h in hosts_csv.split(",") if h.strip()])
52+
53+
54+
def _build_launch_argv(
55+
*, mlx_launch: str, hosts_csv: str, world_size: int,
56+
num_steps: int, lr: float, out: Path,
57+
) -> list[str]:
58+
"""Construct the mlx.launch argv that runs the loopback bench."""
59+
return [
60+
mlx_launch, "-n", str(world_size),
61+
"--hosts", hosts_csv,
62+
sys.executable, str(BENCH_SCRIPT),
63+
"--steps", str(num_steps),
64+
"--lr", str(lr),
65+
"--out", str(out),
66+
]
67+
68+
69+
def _build_simulate_argv(
70+
*, num_steps: int, lr: float, out: Path,
71+
) -> list[str]:
72+
"""Construct the in-process simulation argv (no mlx.launch needed)."""
73+
return [
74+
sys.executable, str(BENCH_SCRIPT),
75+
"--simulate",
76+
"--steps", str(num_steps),
77+
"--lr", str(lr),
78+
"--out", str(out),
79+
]
80+
81+
82+
def _build_parser() -> argparse.ArgumentParser:
83+
p = argparse.ArgumentParser(
84+
prog="python -m cppmega_mlx.cli.smoke_zero1",
85+
description="ZeRO-1 distributed smoke receipt via mlx.launch.",
86+
)
87+
p.add_argument(
88+
"--hosts",
89+
default="127.0.0.1,127.0.0.1",
90+
help=("Comma-separated host list passed to mlx.launch --hosts. "
91+
"Default: 127.0.0.1,127.0.0.1 (2-proc loopback)."),
92+
)
93+
p.add_argument(
94+
"--world-size",
95+
type=int,
96+
default=0,
97+
help=("Explicit -n value for mlx.launch. Defaults to len(hosts)."),
98+
)
99+
p.add_argument("--num-steps", type=int, default=20,
100+
help="Steps per rank + W=1 control (default 20).")
101+
p.add_argument("--lr", type=float, default=1e-4,
102+
help="Learning rate (default 1e-4).")
103+
p.add_argument("--out", type=Path, default=None,
104+
help=(f"Output JSON. Defaults to {DEFAULT_OUT} "
105+
f"(or {DEFAULT_SIMULATE_OUT} for --simulate)."))
106+
p.add_argument("--simulate", action="store_true",
107+
help=("Run the in-process simulation fallback "
108+
"(no mlx.launch needed)."))
109+
p.add_argument("--mlx-launch", default="mlx.launch",
110+
help="Path to mlx.launch (default: looked up on PATH).")
111+
p.add_argument("--dry-run", action="store_true",
112+
help="Print the argv that would be launched and exit 0.")
113+
return p
114+
115+
116+
def main(argv: list[str] | None = None) -> int:
117+
args = _build_parser().parse_args(argv)
118+
119+
if args.simulate:
120+
out = args.out or DEFAULT_SIMULATE_OUT
121+
cmd = _build_simulate_argv(
122+
num_steps=args.num_steps, lr=args.lr, out=out,
123+
)
124+
if args.dry_run:
125+
print(f"[smoke_zero1 dry-run] would run: {cmd}", file=sys.stderr)
126+
return 0
127+
return subprocess.call(cmd)
128+
129+
# Loopback / multi-mac path: requires mlx.launch on PATH.
130+
world_size = _resolve_world_size(args.hosts, args.world_size)
131+
if world_size < 1:
132+
print("[smoke_zero1] world size resolved to 0; "
133+
"pass --hosts or --world-size", file=sys.stderr)
134+
return 64
135+
136+
if not args.dry_run:
137+
# shutil.which honors PATH; explicit absolute paths bypass.
138+
if "/" not in args.mlx_launch:
139+
resolved = shutil.which(args.mlx_launch)
140+
if resolved is None:
141+
print(f"[smoke_zero1] mlx.launch not found on PATH "
142+
f"(searched as {args.mlx_launch!r}). Install MLX "
143+
f"or use --simulate.", file=sys.stderr)
144+
return 64
145+
mlx_launch_bin = resolved
146+
else:
147+
if not Path(args.mlx_launch).exists():
148+
print(f"[smoke_zero1] mlx.launch not found at "
149+
f"{args.mlx_launch} (not on PATH either).",
150+
file=sys.stderr)
151+
return 64
152+
mlx_launch_bin = args.mlx_launch
153+
else:
154+
mlx_launch_bin = args.mlx_launch
155+
156+
out = args.out or DEFAULT_OUT
157+
cmd = _build_launch_argv(
158+
mlx_launch=mlx_launch_bin, hosts_csv=args.hosts,
159+
world_size=world_size, num_steps=args.num_steps,
160+
lr=args.lr, out=out,
161+
)
162+
if args.dry_run:
163+
print(f"[smoke_zero1 dry-run] would run: {cmd}", file=sys.stderr)
164+
return 0
165+
return subprocess.call(cmd)
166+
167+
168+
if __name__ == "__main__":
169+
raise SystemExit(main())

docs/distributed_zero1_smoke_procedure.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -167,9 +167,12 @@ group (which `mlx.launch -n 2` guarantees), the wrapper auto-detects
167167
`world_size = 2` and `rank in {0, 1}` from the global group and only the
168168
local-shard optimizer state is allocated.
169169

170-
Note: `cppmega_mlx.cli.smoke_zero1` is **not yet implemented**. It will be
171-
added alongside the first real receipt; for now, the procedure is documented
172-
ahead of the launcher script so we have a target signature.
170+
Status: `cppmega_mlx.cli.smoke_zero1` is **implemented** (V7-Q05, commit
171+
landed 2026-05-23). It is a thin wrapper around
172+
`scripts/bench_zero1_loopback.py` that constructs the `mlx.launch -n W
173+
--hosts H1,H2,...` command line. Loopback receipts on a single Mac
174+
work today; multi-Mac receipts require peer-48 hand-off (still
175+
external blocker per § "Step 3").
173176

174177
---
175178

tests/v4/test_smoke_zero1_cli.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""V7-Q05: regression for cppmega_mlx.cli.smoke_zero1.
2+
3+
Pin the argparse + command-construction path. We don't actually launch
4+
mlx.launch in CI (multi-process, slow); --dry-run mode exposes the
5+
argv that *would* be invoked and the smoke receipt regeneration is
6+
covered separately in scripts/bench_zero1_loopback.py's own test.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import json
12+
import sys
13+
import tempfile
14+
from pathlib import Path
15+
from unittest import mock
16+
17+
from cppmega_mlx.cli import smoke_zero1
18+
19+
20+
def test_resolve_world_size_from_hosts() -> None:
21+
assert smoke_zero1._resolve_world_size("a,b,c", 0) == 3
22+
assert smoke_zero1._resolve_world_size("a,b", 0) == 2
23+
assert smoke_zero1._resolve_world_size("solo", 0) == 1
24+
25+
26+
def test_resolve_world_size_explicit_override() -> None:
27+
# Explicit --world-size wins over derived count.
28+
assert smoke_zero1._resolve_world_size("a,b", 4) == 4
29+
30+
31+
def test_build_launch_argv_threads_hosts_and_steps() -> None:
32+
argv = smoke_zero1._build_launch_argv(
33+
mlx_launch="mlx.launch",
34+
hosts_csv="127.0.0.1,127.0.0.1",
35+
world_size=2, num_steps=11, lr=2e-4,
36+
out=Path("/tmp/out.json"),
37+
)
38+
# Order matters: mlx.launch -n W --hosts H python script ...
39+
assert argv[0] == "mlx.launch"
40+
assert "-n" in argv and argv[argv.index("-n") + 1] == "2"
41+
assert "--hosts" in argv
42+
assert argv[argv.index("--hosts") + 1] == "127.0.0.1,127.0.0.1"
43+
assert "--steps" in argv and argv[argv.index("--steps") + 1] == "11"
44+
assert "--lr" in argv and argv[argv.index("--lr") + 1] == "0.0002"
45+
assert "--out" in argv
46+
assert argv[argv.index("--out") + 1] == "/tmp/out.json"
47+
48+
49+
def test_build_simulate_argv_inserts_simulate_flag() -> None:
50+
argv = smoke_zero1._build_simulate_argv(
51+
num_steps=7, lr=1e-3, out=Path("/tmp/sim.json"),
52+
)
53+
assert "--simulate" in argv
54+
assert argv[0] == sys.executable
55+
assert "--steps" in argv and argv[argv.index("--steps") + 1] == "7"
56+
57+
58+
def test_dry_run_returns_zero_without_subprocess(capsys) -> None:
59+
code = smoke_zero1.main([
60+
"--hosts", "127.0.0.1,127.0.0.1",
61+
"--num-steps", "2",
62+
"--dry-run",
63+
])
64+
assert code == 0
65+
err = capsys.readouterr().err
66+
# The dry-run path prints the would-be command on stderr.
67+
assert "smoke_zero1" in err
68+
assert "127.0.0.1,127.0.0.1" in err
69+
70+
71+
def test_dry_run_simulate_returns_zero(capsys) -> None:
72+
code = smoke_zero1.main(["--simulate", "--dry-run"])
73+
assert code == 0
74+
err = capsys.readouterr().err
75+
assert "--simulate" in err
76+
77+
78+
def test_missing_mlx_launch_returns_ex_usage(capsys) -> None:
79+
"""When mlx.launch is not on PATH (non --simulate path), exit 64."""
80+
with mock.patch("shutil.which", return_value=None):
81+
code = smoke_zero1.main([
82+
"--hosts", "127.0.0.1,127.0.0.1",
83+
"--mlx-launch", "/nonexistent/mlx.launch",
84+
])
85+
assert code == 64
86+
err = capsys.readouterr().err
87+
assert "not found" in err or "PATH" in err
88+
89+
90+
def test_out_path_default_loopback() -> None:
91+
"""--out default for non-simulate is bench/baselines/zero1_smoke.json."""
92+
assert smoke_zero1.DEFAULT_OUT.name == "zero1_smoke.json"
93+
assert smoke_zero1.DEFAULT_SIMULATE_OUT.name == \
94+
"zero1_simulated_2proc_m4.json"

0 commit comments

Comments
 (0)