Skip to content

Commit 10c8a14

Browse files
committed
feat(v7-c02): sharded checkpoint save + index.json + per-rank load
Closes V7-C02 (cppmega-mlx-z6dj) building block: cppmega_v4.runtime.checkpoint_shard.save_sharded writes one safetensors file per shard plus a model.index.json mapping param_key → shard_file_basename. load_sharded reconstructs the full state; load_sharded_for_rank skips shards not owned by the calling rank (round-robin shard%world_size==rank). Tests (tests/v4/test_checkpoint_shard.py): 4/4 — * 4-shard round trip equal to source state. * index.json carries metadata + weight_map for every key. * world_size=2 round-robin: rank 0 + rank 1 combined keys equal full state. * num_shards=0 rejected. CheckpointStage._save_weights swap from single-file to save_sharded is a separate follow-up that needs to coordinate with the distributed-train path (V7-B01).
1 parent 530375f commit 10c8a14

2 files changed

Lines changed: 179 additions & 0 deletions

File tree

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"""V7-C02: sharded safetensors save with index.json manifest.
2+
3+
save_sharded(state, out_dir, prefix='model', num_shards=N)
4+
Writes:
5+
out_dir/{prefix}.shard-00001-of-NNNNN.safetensors (N files)
6+
out_dir/{prefix}.index.json
7+
{ "metadata": {"total_size": int, "num_shards": int},
8+
"weight_map": {"param_key": "shard_file_basename"} }
9+
10+
load_sharded(index_path) → dict[str, mx.array]
11+
Reads each shard listed in the index and merges into one dict.
12+
13+
load_sharded_for_rank(index_path, rank, world_size) → dict
14+
Skips shards not owned by this rank (round-robin assignment).
15+
"""
16+
17+
from __future__ import annotations
18+
19+
import json
20+
import pathlib
21+
from typing import Any
22+
23+
import mlx.core as mx
24+
import safetensors.mlx as st
25+
26+
27+
def _shard_name(prefix: str, idx: int, total: int) -> str:
28+
return f"{prefix}.shard-{idx + 1:05d}-of-{total:05d}.safetensors"
29+
30+
31+
def save_sharded(state: dict[str, mx.array],
32+
out_dir: str | pathlib.Path,
33+
*, prefix: str = "model",
34+
num_shards: int = 1,
35+
metadata: dict[str, str] | None = None
36+
) -> dict[str, Any]:
37+
if num_shards <= 0:
38+
raise ValueError("num_shards must be > 0")
39+
out_dir = pathlib.Path(out_dir)
40+
out_dir.mkdir(parents=True, exist_ok=True)
41+
keys = sorted(state.keys())
42+
shards: list[dict[str, mx.array]] = [
43+
{} for _ in range(num_shards)]
44+
weight_map: dict[str, str] = {}
45+
total_size = 0
46+
for i, k in enumerate(keys):
47+
shard_idx = i % num_shards
48+
shards[shard_idx][k] = state[k]
49+
fname = _shard_name(prefix, shard_idx, num_shards)
50+
weight_map[k] = fname
51+
if hasattr(state[k], "shape"):
52+
total_size += int(state[k].size) * int(state[k].dtype.size)
53+
for i, shard in enumerate(shards):
54+
fname = _shard_name(prefix, i, num_shards)
55+
st.save_file(shard, str(out_dir / fname), metadata=metadata)
56+
index = {
57+
"metadata": {"total_size": int(total_size),
58+
"num_shards": int(num_shards)},
59+
"weight_map": weight_map,
60+
}
61+
index_path = out_dir / f"{prefix}.index.json"
62+
index_path.write_text(json.dumps(index, indent=2, sort_keys=True))
63+
return index
64+
65+
66+
def load_sharded(index_path: str | pathlib.Path) -> dict[str, mx.array]:
67+
index_path = pathlib.Path(index_path)
68+
index = json.loads(index_path.read_text())
69+
base = index_path.parent
70+
out: dict[str, mx.array] = {}
71+
seen_shards: set[str] = set()
72+
for k, shard_name in index["weight_map"].items():
73+
if shard_name in seen_shards:
74+
continue
75+
seen_shards.add(shard_name)
76+
out.update(st.load_file(str(base / shard_name)))
77+
return out
78+
79+
80+
def load_sharded_for_rank(index_path: str | pathlib.Path,
81+
*, rank: int, world_size: int
82+
) -> dict[str, mx.array]:
83+
"""Round-robin shard ownership: rank r reads shards i where
84+
i % world_size == r."""
85+
if world_size <= 0 or not (0 <= rank < world_size):
86+
raise ValueError("invalid rank/world_size")
87+
index_path = pathlib.Path(index_path)
88+
index = json.loads(index_path.read_text())
89+
base = index_path.parent
90+
num_shards = int(index["metadata"]["num_shards"])
91+
owned = {i for i in range(num_shards) if i % world_size == rank}
92+
out: dict[str, mx.array] = {}
93+
for k, shard_name in index["weight_map"].items():
94+
# Reverse-derive shard index from filename "*-NNNNN-of-MMMMM"
95+
try:
96+
idx = int(shard_name.split("-")[-3]) - 1
97+
except (ValueError, IndexError):
98+
continue
99+
if idx in owned:
100+
if shard_name not in {n for n in
101+
(index["weight_map"][kk]
102+
for kk in index["weight_map"])}:
103+
continue
104+
# Load each shard once.
105+
if shard_name not in out.get("__loaded__", set()):
106+
out.update(st.load_file(str(base / shard_name)))
107+
return out
108+
109+
110+
__all__ = ["save_sharded", "load_sharded", "load_sharded_for_rank"]

tests/v4/test_checkpoint_shard.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""V7-C02: sharded checkpoint save + index.json + load round-trip."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
7+
import mlx.core as mx
8+
import pytest
9+
10+
from cppmega_v4.runtime.checkpoint_shard import (
11+
load_sharded, load_sharded_for_rank, save_sharded,
12+
)
13+
14+
15+
def _state(seed: int = 0) -> dict[str, mx.array]:
16+
return {
17+
"layer0.w": mx.random.normal(shape=(8, 16),
18+
key=mx.random.key(seed)),
19+
"layer0.b": mx.random.normal(shape=(16,),
20+
key=mx.random.key(seed + 1)),
21+
"layer1.w": mx.random.normal(shape=(16, 8),
22+
key=mx.random.key(seed + 2)),
23+
"layer1.b": mx.random.normal(shape=(8,),
24+
key=mx.random.key(seed + 3)),
25+
"head.w": mx.random.normal(shape=(8, 4),
26+
key=mx.random.key(seed + 4)),
27+
}
28+
29+
30+
def test_v7_c02_round_trip_4_shards(tmp_path):
31+
state = _state()
32+
save_sharded(state, tmp_path, num_shards=4)
33+
index = json.loads((tmp_path / "model.index.json").read_text())
34+
assert index["metadata"]["num_shards"] == 4
35+
assert set(index["weight_map"].keys()) == set(state.keys())
36+
37+
loaded = load_sharded(tmp_path / "model.index.json")
38+
assert set(loaded.keys()) == set(state.keys())
39+
for k in state:
40+
assert mx.allclose(loaded[k], state[k], atol=0.0)
41+
42+
43+
def test_v7_c02_index_json_has_weight_map_and_total_size(tmp_path):
44+
state = _state()
45+
save_sharded(state, tmp_path, num_shards=2)
46+
idx = json.loads((tmp_path / "model.index.json").read_text())
47+
assert "metadata" in idx and "weight_map" in idx
48+
assert idx["metadata"]["total_size"] > 0
49+
# Every param key maps to a shard filename.
50+
for k in state:
51+
assert idx["weight_map"][k].endswith(".safetensors")
52+
53+
54+
def test_v7_c02_load_sharded_for_rank_round_robin(tmp_path):
55+
state = _state()
56+
save_sharded(state, tmp_path, num_shards=4)
57+
# 4 shards, world_size=2 → rank 0 owns shards {0,2}; rank 1 owns {1,3}
58+
r0 = load_sharded_for_rank(
59+
tmp_path / "model.index.json", rank=0, world_size=2)
60+
r1 = load_sharded_for_rank(
61+
tmp_path / "model.index.json", rank=1, world_size=2)
62+
# Combined coverage equals full state.
63+
combined = {**r0, **r1}
64+
assert set(combined.keys()) == set(state.keys())
65+
66+
67+
def test_v7_c02_num_shards_validation(tmp_path):
68+
with pytest.raises(ValueError):
69+
save_sharded({"k": mx.zeros((2,))}, tmp_path, num_shards=0)

0 commit comments

Comments
 (0)