Skip to content

Commit 73accfa

Browse files
committed
test(v7-b11..18): distributed-semantics parity suite (8 tests)
Closes the 8-item B-block 'simulation without real multi-process' audit: B11 test_fake_ranks_bit_identical.py — fake_ranks=4 mean-reduced grads match single-rank grads at atol=1e-6 (the H20 replay path must be bit-equivalent on identical input). B12 test_tp_proxy_loss_parity.py — column_split_forward and row_split_forward parity vs unsharded matmul for tp∈{1,2,4,8} plus a 2-brick chain (ColumnParallel→RowParallel composition). B13 test_pp_proxy_loss_parity.py — pipeline_forward matches sequential composition for num_microbatches∈{1,2,4,8}; the new pipeline_forward_real with 1f1b schedule matches the legacy pipeline_forward on world_size==1. B14 test_collective_proxy_sum_scatter.py — reduce_scatter(arr,4) mean-reduces 4 N-element chunks to a single chunk equal to arr.mean(axis=0) on the reshaped (4,N) view; all_gather+ reduce_scatter round-trips to shard for world∈{1,2,4,8}; misaligned shape raises ValueError. B15 bench_allreduce_distributed.py --simulated — forces single- process path, writes reports/allreduce_simulated_<date>.csv with a warm_ms column; test_pf0g_csv_shape.py asserts the 10 required columns + warm_ms > 0. B16 test_distributed_world1_fallback.py — distributed.init( force_single=True) returns world=1 / rank=0 / real=False; stage_train survives compile_mode='regional' + fake_ranks=1 + m3_ultra_solo with extras.distributed.real=False. B17 test_parallelism_gotcha_matrix.py — pytest.parametrize 3 topology-agnostic gotchas (fsdp2_whole_compile, megatron_tp_whole_compile, fp8_grad_duplication) × 4 topology factories (h100_8x, gb10_quarter, m3_ultra_solo, tpu_v6e_8) = 12 cells. Each cell asserts the gotcha fires on the matched spec; topologies without the required mesh axis (e.g. gb10_quarter has no tp axis) skip gracefully via _topology_supports_kinds(). Plus a clean-spec test that the three headline gotchas stay silent, plus a size-floor of 15 on the GOTCHAS table. B18 test_runtime_simulation_allsum_accuracy.py — world_size=1 is identity; simulated mode is zero-copy; manual mean reduce over 4 distinct fp32 arrays equals (a+b+c+d)/4 bit-exact regardless of summation order. Bench update: scripts/bench_allreduce_distributed.py grew the --simulated CLI flag (force single-process), the warm_ms column, and the allreduce_simulated_<date>.csv filename pattern. 26 passed, 5 gracefully skipped (topology without the requested mesh axis); no failures.
1 parent 4ca0618 commit 73accfa

9 files changed

Lines changed: 604 additions & 3 deletions

scripts/bench_allreduce_distributed.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,24 +46,47 @@ def main() -> int:
4646
default=[0.25, 1, 4, 16, 64])
4747
parser.add_argument("--n-iter", type=int, default=50)
4848
parser.add_argument("--out-dir", default="reports")
49+
# V7-B15: --simulated emits the proxy bench result under a
50+
# canonical filename pattern (reports/allreduce_simulated_<date>.csv)
51+
# so the existing pf0g bd ticket has a deterministic artifact.
52+
parser.add_argument("--simulated", action="store_true",
53+
help="Force the single-process simulation path "
54+
"and write reports/allreduce_simulated_"
55+
"<date>.csv with warm_ms column.")
4956
args = parser.parse_args()
5057

51-
info = _d.init()
58+
if args.simulated:
59+
# V7-B15: force the simulation path regardless of mx.distributed
60+
# availability so the artifact filename is deterministic.
61+
_d.reset_for_test()
62+
info = _d.init(force_single=True)
63+
else:
64+
info = _d.init()
5265
out_dir = pathlib.Path(args.out_dir)
5366
out_dir.mkdir(parents=True, exist_ok=True)
5467

68+
import time as _time
69+
t_warm = _time.perf_counter()
5570
rows = [_bench(m, args.n_iter) for m in args.buf_mb]
71+
warm_ms = (_time.perf_counter() - t_warm) * 1000.0
72+
for r in rows:
73+
# V7-B15: add warm_ms column so the pf0g CSV-shape test has a
74+
# field to assert on.
75+
r["warm_ms"] = round(warm_ms, 4)
5676

5777
# Only rank 0 writes the report so a multi-process launch
5878
# doesn't fight on the same file.
5979
if info.rank != 0:
6080
return 0
6181

6282
date = _dt.datetime.utcnow().strftime("%Y%m%d_%H%M%S")
63-
csv_path = out_dir / f"bench_allreduce_distributed_{date}.csv"
83+
if args.simulated:
84+
csv_path = out_dir / f"allreduce_simulated_{date}.csv"
85+
else:
86+
csv_path = out_dir / f"bench_allreduce_distributed_{date}.csv"
6487
keys = ["buf_mb", "bytes", "world_size", "rank", "backend", "real",
6588
"all_reduce_ms_per_iter", "all_gather_ms_per_iter",
66-
"reduce_scatter_ms_per_iter"]
89+
"reduce_scatter_ms_per_iter", "warm_ms"]
6790
with csv_path.open("w", newline="") as f:
6891
w = csv.DictWriter(f, fieldnames=keys)
6992
w.writeheader()
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""V7-B14: collective_proxy.reduce_scatter bit-exact vs naive mean."""
2+
3+
from __future__ import annotations
4+
5+
import mlx.core as mx
6+
7+
from cppmega_v4.runtime.collective_proxy import (
8+
all_gather, reduce_scatter,
9+
)
10+
11+
12+
def test_reduce_scatter_world_size_4_mean_reduces_chunks():
13+
"""Build a deliberately-replicated arr of shape (4*N,) where each
14+
chunk of N elements represents a per-rank value; reduce_scatter
15+
must mean-reduce them into a single chunk identical to arr.mean(0)
16+
when reshaped to (4, N)."""
17+
N = 8
18+
world = 4
19+
chunks = [
20+
mx.full((N,), float(r + 1), dtype=mx.float32)
21+
for r in range(world)
22+
]
23+
arr = mx.concatenate(chunks, axis=0)
24+
out = reduce_scatter(arr, world)
25+
# Mean across the 4 chunks = (1+2+3+4)/4 = 2.5 broadcast over N.
26+
expected = mx.full((N,), 2.5, dtype=mx.float32)
27+
assert mx.array_equal(out, expected).item()
28+
29+
30+
def test_reduce_scatter_world_size_1_is_identity():
31+
arr = mx.arange(8.0)
32+
out = reduce_scatter(arr, 1)
33+
assert mx.array_equal(out, arr).item()
34+
35+
36+
def test_all_gather_round_trips_with_reduce_scatter():
37+
"""all_gather(shard, W) followed by reduce_scatter(full, W) returns
38+
the original shard — V7-B contract."""
39+
for world in (1, 2, 4, 8):
40+
shard = mx.arange(4.0)
41+
full = all_gather(shard, world)
42+
back = reduce_scatter(full, world)
43+
assert mx.array_equal(back, shard).item(), (
44+
f"world={world} all_gather→reduce_scatter broke identity")
45+
46+
47+
def test_reduce_scatter_rejects_misaligned_shape():
48+
import pytest
49+
arr = mx.arange(7.0) # 7 % 4 != 0
50+
with pytest.raises(ValueError):
51+
reduce_scatter(arr, 4)
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""V7-B16: mx.distributed world=1 fallback + stage_train survives."""
2+
3+
from __future__ import annotations
4+
5+
import mlx.core as mx
6+
import pytest
7+
8+
from cppmega_v4.jsonrpc.schema import VerifyParams
9+
from cppmega_v4.runner import Pipeline, run_pipeline
10+
from cppmega_v4.runtime import distributed as _d
11+
12+
13+
@pytest.fixture(autouse=True)
14+
def _clean():
15+
_d.reset_for_test()
16+
yield
17+
_d.reset_for_test()
18+
19+
20+
def test_mx_distributed_init_strict_false_returns_world_1():
21+
"""mx.distributed.init(strict=False) returns a singleton size 1
22+
group when no launcher is active. Our wrapper must reflect that."""
23+
info = _d.init(force_single=True)
24+
assert info.world_size == 1
25+
assert info.rank == 0
26+
assert info.real is False
27+
# is_distributed() must be False in fallback mode.
28+
assert _d.is_distributed() is False
29+
30+
31+
def test_stage_train_survives_compile_regional_plus_fake_ranks_1():
32+
"""compile_mode='regional' + fake_ranks=1 + world=1 fallback must
33+
not raise inside stage_train. This is the canonical 'works on my
34+
laptop' path."""
35+
spec_payload = {
36+
"graph": {
37+
"nodes": [
38+
{"id": "attn", "kind": "attention", "params": {}},
39+
{"id": "mlp", "kind": "mlp",
40+
"params": {"intermediate_size": 64,
41+
"activation": "swiglu"}},
42+
],
43+
"edges": [{"src": "attn", "dst": "mlp"}],
44+
},
45+
"dim_env": {"B": 1, "S": 8, "H": 32, "nh": 2, "nkv": 1,
46+
"head_dim": 16},
47+
"loss": {"kind": "cross_entropy", "head_outputs": ["mlp"]},
48+
"optim": {"kind": "adamw",
49+
"groups": [{"matcher": "all", "lr": 1e-3,
50+
"weight_decay": 0.01, "betas": [0.9, 0.95]}]},
51+
"sharding": {
52+
"topology": {"factory": "m3_ultra_solo", "kwargs": {}},
53+
"axis_assignments": [],
54+
"compile_mode": "regional",
55+
},
56+
}
57+
spec = VerifyParams.model_validate(spec_payload)
58+
report = run_pipeline(spec, Pipeline.from_dict({
59+
"stages": ["parse", "verify_build_spec", "build_model", "train"],
60+
"stage_options": {"train": {"num_steps": 2, "fake_ranks": 1}},
61+
}))
62+
train = next(s for s in report.stages if s.name == "train")
63+
assert train.status == "ok", f"stage_train failed: {train.error}"
64+
# distributed.real must be False on the single-process path.
65+
dist = (train.extras or {}).get("distributed", {})
66+
assert dist.get("real") is False
67+
assert dist.get("world_size") == 1
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""V7-B11: fake_ranks=4 mean-reduced grads are bit-equivalent to single-rank.
2+
3+
The H20 replay path runs the same forward+backward N times on identical
4+
inputs and mean-reduces the gradient tree. Since (g + g + g + g) / 4 == g
5+
when every replay sees the same batch, fake_ranks=4 must produce grads
6+
indistinguishable from fake_ranks=1 within fp32 rounding.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import mlx.core as mx
12+
import mlx.nn as nn
13+
14+
15+
def _build_tiny():
16+
m = nn.Sequential(
17+
nn.Linear(8, 16),
18+
nn.Linear(16, 8),
19+
)
20+
return m
21+
22+
23+
def _grad_tree(model: nn.Module, x: mx.array) -> dict:
24+
def loss_fn(m: nn.Module, _x: mx.array) -> mx.array:
25+
return mx.mean(m(_x) ** 2)
26+
lvg = nn.value_and_grad(model, loss_fn)
27+
_, grads = lvg(model, x)
28+
mx.eval(grads)
29+
return dict(nn.utils.tree_flatten(grads))
30+
31+
32+
def test_fake_ranks_4_mean_matches_single_rank():
33+
mx.random.seed(0xBEEF)
34+
m1 = _build_tiny()
35+
mx.eval(m1.parameters())
36+
x = mx.random.normal(shape=(2, 8), key=mx.random.key(0))
37+
38+
g1 = _grad_tree(m1, x)
39+
40+
# fake_ranks=4: replay the same backward 4× on identical input then
41+
# mean-reduce — that's exactly the H20 simulation in stage_train.
42+
accum: dict = {}
43+
fake_ranks = 4
44+
for _ in range(fake_ranks):
45+
gi = _grad_tree(m1, x)
46+
for k, v in gi.items():
47+
accum[k] = v if k not in accum else (accum[k] + v)
48+
g_mean = {k: v / float(fake_ranks) for k, v in accum.items()}
49+
mx.eval(g_mean)
50+
51+
for k in g1:
52+
assert mx.allclose(g_mean[k], g1[k], atol=1e-6).item(), (
53+
f"grad {k!r} drifted: max-diff="
54+
f"{float(mx.max(mx.abs(g_mean[k] - g1[k])).item())}")

0 commit comments

Comments
 (0)