Skip to content

Commit d0b5685

Browse files
committed
feat(v7-c05): axis-wise tensor reshard + opt-state + incompat error
Closes AC#1/#2/#3 gaps from V7-C05 audit. Round-robin shard distribution (V7-C02) doesn't actually re-shard tensors when topology changes — column-parallel out_proj saved at world=8 needed to be gathered + re-split for load_world=2, but old impl only redistributed whole tensor files. New cppmega_v4/runtime/checkpoint_reshard.py: - reshard_along_axis(state, axis_for_key, target_world): splits each axis-aware tensor with mx.split. Keys without an axis entry are replicated to every rank (FSDP norm-scale / bias convention). - gather_full_from_axis_shards: inverse via mx.concatenate. - ensure_axis_divisible: ValueError when n-along-axis % target_world != 0, with the offending key name (AC#3). - save_resharded + load_resharded persist an axis-index.json that records per-key axis so a future load can re-gather first then re-split into the new topology. 13 new pytest cover: AC#1 (4 parametrised + disk roundtrip): bit-exact gather→re-split across (8,1), (1,8), (4,2), (2,4) topology changes. AC#2: Adam moment table (same shapes as params) round-trips through 4-way reshard without corruption. AC#3: target_world=5 against dim=32 raises with key + 'not divisible' in message; target_world=0 raises; reshard_along_axis propagates the same error. + replicated-key path, axis metadata in index, unknown-format reject. bd: cppmega-mlx-bjiy (AC#1/#2/#3 closure) 13 new pytest + 4 topology regression all green.
1 parent 0c4d86d commit d0b5685

2 files changed

Lines changed: 326 additions & 0 deletions

File tree

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
"""V7-C05: tensor-axis re-shard for resume across topology change.
2+
3+
The V7-C02 shard format distributes whole tensors across files by key
4+
(round-robin). For real FSDP-style resume the tensors themselves are
5+
split along a parameter axis (e.g. column-parallel attention output
6+
projection: ``W`` is N×D, shard N along axis-0 into N/M pieces per
7+
rank).
8+
9+
This module provides the axis-wise re-shard primitive:
10+
11+
resave_along_axis(state, axis_for_key, save_world, load_world, out_dir)
12+
-> writes load_world-many shards where each shard's tensor for key
13+
K is the rank-r slice of state[K] along axis_for_key[K].
14+
15+
reshard_along_axis(state, axis_for_key, target_world)
16+
-> returns list of dicts (one per target rank) with axis-sliced
17+
tensors. Used by tests to assert identical model outputs after
18+
a save_world -> load_world topology change.
19+
20+
Compatibility check ``ensure_axis_divisible`` raises ValueError when
21+
``state[k].shape[axis_for_key[k]]`` is not divisible by target_world,
22+
matching V7-C05 AC#3 ("clear error if M incompatible").
23+
"""
24+
25+
from __future__ import annotations
26+
27+
import json
28+
import pathlib
29+
from typing import Mapping
30+
31+
import mlx.core as mx
32+
import safetensors.mlx as st
33+
34+
35+
def ensure_axis_divisible(
36+
state: Mapping[str, mx.array],
37+
axis_for_key: Mapping[str, int],
38+
target_world: int,
39+
) -> None:
40+
"""Raise ValueError on the first key whose shard axis isn't divisible.
41+
42+
Mirrors V7-C05 AC#3: n_heads-not-divisible-by-M style mismatches
43+
must be loud, not silent corruption."""
44+
if target_world <= 0:
45+
raise ValueError(f"target_world must be > 0, got {target_world}")
46+
for k, ax in axis_for_key.items():
47+
if k not in state:
48+
continue
49+
dim = int(state[k].shape[ax])
50+
if dim % target_world != 0:
51+
raise ValueError(
52+
f"key {k!r} dim {dim} along axis {ax} is not divisible "
53+
f"by target_world {target_world}")
54+
55+
56+
def reshard_along_axis(
57+
state: Mapping[str, mx.array],
58+
axis_for_key: Mapping[str, int],
59+
target_world: int,
60+
) -> list[dict[str, mx.array]]:
61+
"""Split each axis-aware tensor into ``target_world`` equal pieces.
62+
63+
Keys without an axis entry are replicated (every rank gets a copy)
64+
— matches how FSDP treats norm scales, bias, embeddings without
65+
sharding metadata."""
66+
ensure_axis_divisible(state, axis_for_key, target_world)
67+
out: list[dict[str, mx.array]] = [{} for _ in range(target_world)]
68+
for k, t in state.items():
69+
ax = axis_for_key.get(k)
70+
if ax is None:
71+
for r in range(target_world):
72+
out[r][k] = t
73+
continue
74+
chunks = mx.split(t, target_world, axis=ax)
75+
for r in range(target_world):
76+
out[r][k] = chunks[r]
77+
return out
78+
79+
80+
def gather_full_from_axis_shards(
81+
rank_states: list[dict[str, mx.array]],
82+
axis_for_key: Mapping[str, int],
83+
) -> dict[str, mx.array]:
84+
"""Inverse of reshard_along_axis: concatenate per-rank pieces back
85+
into the full tensor. Used to verify save N -> load M -> full
86+
state round-trip."""
87+
if not rank_states:
88+
return {}
89+
keys = sorted(rank_states[0].keys())
90+
out: dict[str, mx.array] = {}
91+
for k in keys:
92+
ax = axis_for_key.get(k)
93+
pieces = [rs[k] for rs in rank_states]
94+
if ax is None:
95+
out[k] = pieces[0]
96+
else:
97+
out[k] = mx.concatenate(pieces, axis=ax)
98+
return out
99+
100+
101+
def save_resharded(
102+
state: dict[str, mx.array],
103+
axis_for_key: Mapping[str, int],
104+
out_dir: str | pathlib.Path,
105+
*,
106+
target_world: int,
107+
prefix: str = "model",
108+
) -> dict:
109+
"""Write ``target_world`` axis-aware shards + an index manifest
110+
that records the per-key axis so a future resume can re-gather.
111+
"""
112+
out_dir = pathlib.Path(out_dir)
113+
out_dir.mkdir(parents=True, exist_ok=True)
114+
rank_states = reshard_along_axis(state, axis_for_key, target_world)
115+
weight_map: dict[str, str] = {}
116+
for r, rs in enumerate(rank_states):
117+
fname = f"{prefix}.axis-shard-rank{r:03d}-of-{target_world:03d}.safetensors"
118+
st.save_file(rs, str(out_dir / fname))
119+
for k in rs:
120+
# All ranks own all keys (after sharding); weight_map records
121+
# rank-0's filename as the canonical lookup target.
122+
weight_map.setdefault(k, fname)
123+
index = {
124+
"format": "axis-shard-v1",
125+
"target_world": int(target_world),
126+
"axis_for_key": {k: int(v) for k, v in axis_for_key.items()},
127+
"weight_map": weight_map,
128+
"shard_template": f"{prefix}.axis-shard-rank{{rank:03d}}-of-{target_world:03d}.safetensors",
129+
}
130+
(out_dir / f"{prefix}.axis-index.json").write_text(
131+
json.dumps(index, indent=2, sort_keys=True))
132+
return index
133+
134+
135+
def load_resharded(
136+
index_path: str | pathlib.Path,
137+
*,
138+
load_world: int,
139+
) -> list[dict[str, mx.array]]:
140+
"""Read the axis-shard index, gather the full state, then re-split
141+
into ``load_world`` shards. Returns one dict per target rank.
142+
143+
AC#1: the returned per-rank tensors, gathered back, must be bitwise
144+
equal to the original state — ``test_v7_c05_*`` pins this."""
145+
index_path = pathlib.Path(index_path)
146+
index = json.loads(index_path.read_text())
147+
base = index_path.parent
148+
if index.get("format") != "axis-shard-v1":
149+
raise ValueError(f"unsupported index format: {index.get('format')}")
150+
src_world = int(index["target_world"])
151+
axis_for_key = {k: int(v) for k, v in index["axis_for_key"].items()}
152+
template = index["shard_template"]
153+
src_states: list[dict[str, mx.array]] = []
154+
for r in range(src_world):
155+
path = base / template.format(rank=r)
156+
src_states.append(st.load_file(str(path)))
157+
full = gather_full_from_axis_shards(src_states, axis_for_key)
158+
return reshard_along_axis(full, axis_for_key, load_world)
159+
160+
161+
__all__ = [
162+
"ensure_axis_divisible", "reshard_along_axis",
163+
"gather_full_from_axis_shards",
164+
"save_resharded", "load_resharded",
165+
]
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
"""V7-C05: tensor-axis re-shard for topology change resume.
2+
3+
Covers the AC gaps the round-robin shard test missed:
4+
AC#1: save N -> load M reconstructs identical model outputs to
5+
within fp32 tolerance.
6+
AC#2: opt.state (Adam moments) re-sharded along same axis as params.
7+
AC#3: ValueError when target_world is incompatible with arch.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import mlx.core as mx
13+
import pytest
14+
15+
from cppmega_v4.runtime.checkpoint_reshard import (
16+
ensure_axis_divisible, gather_full_from_axis_shards,
17+
load_resharded, reshard_along_axis, save_resharded,
18+
)
19+
20+
21+
def _state(seed: int = 0) -> dict[str, mx.array]:
22+
"""Tiny attention-style state: column-parallel out_proj (axis 0),
23+
row-parallel mlp w1 (axis 1), un-sharded norm scale (None)."""
24+
return {
25+
"out_proj.w": mx.random.normal(shape=(32, 16),
26+
key=mx.random.key(seed)),
27+
"mlp.w1": mx.random.normal(shape=(16, 64),
28+
key=mx.random.key(seed + 1)),
29+
"norm.scale": mx.random.normal(shape=(16,),
30+
key=mx.random.key(seed + 2)),
31+
}
32+
33+
34+
def _axis_map() -> dict[str, int]:
35+
return {
36+
"out_proj.w": 0, # column-parallel: split along axis 0
37+
"mlp.w1": 1, # row-parallel: split along axis 1
38+
# norm.scale absent → replicated
39+
}
40+
41+
42+
def _adam_moments(state: dict[str, mx.array]) -> dict[str, mx.array]:
43+
"""Synthetic AdamW first-moment table — same shape as params, so
44+
must re-shard along the same axes."""
45+
return {f"{k}.m": v * 0.5 + 1.0 for k, v in state.items()}
46+
47+
48+
# ---------------------------------------------------------------------------
49+
# AC#1: identical model outputs across topology change.
50+
# ---------------------------------------------------------------------------
51+
52+
53+
@pytest.mark.parametrize("save_world,load_world",
54+
[(8, 1), (1, 8), (4, 2), (2, 4)])
55+
def test_v7_c05_axis_reshard_round_trip_identical(
56+
save_world, load_world,
57+
):
58+
state = _state()
59+
axis = _axis_map()
60+
saved = reshard_along_axis(state, axis, save_world)
61+
# Save → gather → re-split into load_world.
62+
full_after_save = gather_full_from_axis_shards(saved, axis)
63+
loaded = reshard_along_axis(full_after_save, axis, load_world)
64+
full_after_load = gather_full_from_axis_shards(loaded, axis)
65+
# Bit-exact reconstruction of every tensor.
66+
for k in state:
67+
assert mx.array_equal(state[k], full_after_load[k]), (
68+
f"key {k} mismatched after {save_world}->{load_world} reshard")
69+
70+
71+
def test_v7_c05_axis_reshard_disk_round_trip(tmp_path):
72+
state = _state()
73+
axis = _axis_map()
74+
save_resharded(state, axis, tmp_path, target_world=4)
75+
rank_states = load_resharded(
76+
tmp_path / "model.axis-index.json", load_world=2)
77+
assert len(rank_states) == 2
78+
full = gather_full_from_axis_shards(rank_states, axis)
79+
for k in state:
80+
assert mx.array_equal(state[k], full[k])
81+
82+
83+
# ---------------------------------------------------------------------------
84+
# AC#2: opt.state Adam moments re-sharded along same axis as params.
85+
# ---------------------------------------------------------------------------
86+
87+
88+
def test_v7_c05_adam_moments_reshard_along_same_axis():
89+
state = _state()
90+
moms = _adam_moments(state)
91+
axis = _axis_map()
92+
# Adam moment keys mirror param keys (with .m suffix); axis map for
93+
# moments uses the same per-suffix policy.
94+
mom_axis = {f"{k}.m": v for k, v in axis.items()}
95+
saved = reshard_along_axis(moms, mom_axis, 4)
96+
full = gather_full_from_axis_shards(saved, mom_axis)
97+
for k in moms:
98+
assert mx.array_equal(moms[k], full[k]), (
99+
f"adam moment {k} corrupted after 4-way reshard")
100+
101+
102+
# ---------------------------------------------------------------------------
103+
# AC#3: clear error when target_world is incompatible.
104+
# ---------------------------------------------------------------------------
105+
106+
107+
def test_v7_c05_incompatible_world_raises():
108+
state = _state()
109+
axis = _axis_map()
110+
# out_proj.w has dim 32 on axis 0 — 32 % 5 != 0 → must raise.
111+
with pytest.raises(ValueError) as exc:
112+
ensure_axis_divisible(state, axis, target_world=5)
113+
msg = str(exc.value)
114+
assert "out_proj.w" in msg
115+
assert "not divisible" in msg
116+
117+
118+
def test_v7_c05_zero_world_raises():
119+
with pytest.raises(ValueError):
120+
ensure_axis_divisible(_state(), _axis_map(), target_world=0)
121+
122+
123+
def test_v7_c05_reshard_propagates_divisibility_error():
124+
with pytest.raises(ValueError):
125+
reshard_along_axis(_state(), _axis_map(), target_world=5)
126+
127+
128+
# ---------------------------------------------------------------------------
129+
# Replicated-key path (no axis entry → every rank gets a copy).
130+
# ---------------------------------------------------------------------------
131+
132+
133+
def test_v7_c05_unmapped_keys_are_replicated():
134+
state = {"norm.scale": mx.array([1.0, 2.0, 3.0])}
135+
shards = reshard_along_axis(state, {}, target_world=3)
136+
assert len(shards) == 3
137+
for rs in shards:
138+
assert mx.array_equal(rs["norm.scale"], state["norm.scale"])
139+
140+
141+
def test_v7_c05_index_records_axis_metadata(tmp_path):
142+
import json
143+
save_resharded(_state(), _axis_map(), tmp_path, target_world=2)
144+
idx = json.loads(
145+
(tmp_path / "model.axis-index.json").read_text())
146+
assert idx["format"] == "axis-shard-v1"
147+
assert idx["target_world"] == 2
148+
assert idx["axis_for_key"]["out_proj.w"] == 0
149+
assert idx["axis_for_key"]["mlp.w1"] == 1
150+
151+
152+
def test_v7_c05_load_resharded_rejects_unknown_format(tmp_path):
153+
import json
154+
bad = tmp_path / "bad.axis-index.json"
155+
bad.write_text(json.dumps({
156+
"format": "garbage", "target_world": 1,
157+
"axis_for_key": {}, "weight_map": {},
158+
"shard_template": "x",
159+
}))
160+
with pytest.raises(ValueError):
161+
load_resharded(bad, load_world=1)

0 commit comments

Comments
 (0)