moonclip 0.0.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,82 @@
1
+ /target
2
+ # Generated benchmark checkpoints (~4 GB), not the benchmark sources. The
3
+ # rule used to be /bench*, which also swallowed benches/ and bench/ — source
4
+ # directories that belong in the repo.
5
+ /bench_baseline/
6
+
7
+ # Byte-compiled / optimized / DLL files
8
+ __pycache__/
9
+ .pytest_cache/
10
+ *.py[cod]
11
+
12
+ # Native extensions
13
+ *.so
14
+ *.pyd
15
+ *.pdb
16
+
17
+ # Distribution / packaging
18
+ .Python
19
+ .venv/
20
+ env/
21
+ bin/
22
+ build/
23
+ develop-eggs/
24
+ dist/
25
+ eggs/
26
+ lib/
27
+ lib64/
28
+ parts/
29
+ sdist/
30
+ var/
31
+ include/
32
+ man/
33
+ venv/
34
+ *.egg-info/
35
+ .installed.cfg
36
+ *.egg
37
+
38
+ # Installer logs
39
+ pip-log.txt
40
+ pip-delete-this-directory.txt
41
+ pip-selfcheck.json
42
+
43
+ # Unit test / coverage reports
44
+ htmlcov/
45
+ .tox/
46
+ .coverage
47
+ .cache
48
+ nosetests.xml
49
+ coverage.xml
50
+
51
+ # Translations
52
+ *.mo
53
+
54
+ # Mr Developer
55
+ .mr.developer.cfg
56
+ .project
57
+ .pydevproject
58
+
59
+ # Rope
60
+ .ropeproject
61
+
62
+ # Django stuff:
63
+ *.log
64
+ *.pot
65
+
66
+ .DS_Store
67
+
68
+ # Sphinx documentation
69
+ docs/_build/
70
+
71
+ # PyCharm
72
+ .idea/
73
+
74
+ # VSCode
75
+ .vscode/
76
+
77
+ # Pyenv
78
+ .python-version
79
+
80
+ # Training artifacts
81
+ moonclip_*_ckpts/
82
+ .claude
@@ -0,0 +1,224 @@
1
+ # Changelog
2
+
3
+ ## 0.0.6 — unreleased
4
+
5
+ Everything an external review of the 0.0.4 artifacts found, plus the four things
6
+ that turned up while fixing them. Each entry below has a regression test that
7
+ fails on 0.0.4.
8
+
9
+ **There is no 0.0.5.** It was prepared, merged and never published: the review
10
+ that produced the rest of this list arrived first, and shipping the small half
11
+ on its own would have meant a release whose headline fix was a warning about a
12
+ bug that is now fixed. Its entries are folded in below.
13
+
14
+ Nothing here changes the pack format. A 0.0.4 checkpoint reads unchanged, and
15
+ one written by 0.0.6 is readable by 0.0.4 — with the same bugs.
16
+
17
+ ### Fixed — data loss
18
+
19
+ - **Merging dropped tensors the model gained and resurrected ones it lost.**
20
+ `do_full_merge` rebuilt the merged snapshot from the **base** snapshot's
21
+ tensor list, so the model's shape at the base decided what survived the fold.
22
+ An adapter added mid-run, a growing head, a pruned layer: the merged
23
+ checkpoint loaded without complaint, holding the wrong set of parameters. It
24
+ walks the newest delta now — that list *is* the state dict at the step the
25
+ merge stands in for, and it is what a load of that snapshot would return.
26
+ Two more defects came with it: intermediate deltas were replayed in sequence,
27
+ which corrupted any tensor that changed and changed back (the newest delta
28
+ says "identical to the base" and the replay left the intermediate value), and
29
+ `original_dtype` was dropped, so merging a `save_dtype="bf16"` checkpoint
30
+ quietly stopped uncasting it on load. Merging is off by default
31
+ (`merge_stride=0`), so this only ever reached people who turned it on.
32
+ - **A merge could delete the base a save was reading.** `save_sync` takes its
33
+ base from the manifest and releases the lock before reading it; the merger
34
+ holds the lock only while deciding what to fold. Overlapping those windows
35
+ failed the save outright (`Checkpoint not found: snapshots/…/rank_0.pack`)
36
+ or, worse, published a delta pointing at a base the merge had already
37
+ removed — broken on the next `load_latest`, and discarded by startup recovery
38
+ as an orphan. There is now a registry of what is being read
39
+ (`crate::inflight`): a merge does not consume a snapshot somebody is inside,
40
+ and a save does not pick up a base a merge has claimed. Neither side waits
41
+ for the other — the merge stands down and runs on the next notify, the save
42
+ writes a full snapshot for that one step.
43
+ - **Multi-rank saves lost each other's shards.** Every rank reloaded
44
+ `manifest.json`, inserted its own entry and wrote the whole file back — a
45
+ read-modify-write with no lock, across processes. The Python layer hid it by
46
+ serialising the ranks behind a barrier, which made the README's "each rank
47
+ saves its own shard independently" false and a multi-GPU save sequential. A
48
+ rank now writes only its own pack; `finalize_snapshot` assembles the snapshot
49
+ from the descriptors in those packs. One writer, no lock, and the shards go
50
+ in parallel.
51
+
52
+ ### Fixed — checkpoints that could not be read or recovered
53
+
54
+ - **Tied weights became unreadable when the tensor order changed.**
55
+ Deduplication stores the first of a set of identical tensors and records the
56
+ rest as aliases of it. Which one comes first is the state dict's iteration
57
+ order, and wrapping a model differently is enough to swap them: the tensor
58
+ that used to be the alias then carries the bytes, is unchanged since the
59
+ base, and is written `Skipped` — and resolving that skip landed on the base's
60
+ alias entry, which holds no bytes. `Tensor 'head' is an alias of 'emb' and
61
+ must be resolved after the snapshot's other tensors`, on a checkpoint that
62
+ was completely intact. The base lookup follows the reference now.
63
+ - **A checkpoint where nothing changed could not be recovered.** With every
64
+ tensor skipped there are no bytes to write, so no pack was written — and the
65
+ pack is what carries the descriptor that startup recovery rebuilds a lost
66
+ snapshot from. A process killed between the save and the manifest write left
67
+ that checkpoint unrecoverable, in the case that is ordinary rather than
68
+ exotic: fine-tuning, where whole stretches of the model do not move. A pack
69
+ holding just the descriptor is always written now, a few hundred bytes.
70
+ - **No `fsync` anywhere in the write path.** The temp-file-then-rename was
71
+ atomic for readers and said nothing about power: the rename could reach the
72
+ disk while the data was still in the page cache, leaving a pack of exactly
73
+ the right length full of zeros, vouched for by the manifest. `flush()`
74
+ documented "durably on disk" and did not deliver it. The data is now synced
75
+ before the rename and the directory after it. `MOONCLIP_FSYNC=0` restores the
76
+ old behaviour where the checkpoint is not the thing being protected.
77
+
78
+ ### Fixed — quietly wrong
79
+
80
+ - **The fp32→bf16 cast could turn a NaN into +Inf.** bf16 keeps the top 7
81
+ mantissa bits, so a NaN whose payload sits below bit 16 — `0x7F800001`, which
82
+ is what comparisons against a corrupted tensor tend to produce — reached the
83
+ truncation with a zero mantissa under an all-ones exponent, and that is
84
+ infinity. The quiet NaN `0x7FC00000` was never affected, which is why no test
85
+ caught it. NaN is mapped to a NaN explicitly now, sign and the surviving
86
+ payload bits kept. It only ever affected `save_dtype="bf16"`, and what it
87
+ cost was the evidence: a diverged run saved looking finite.
88
+ - **`max_rollback_snapshots` was never read.** Defined, defaulted, documented
89
+ and passed in from Python, and no code consulted it — so every snapshot that
90
+ ever landed on `rollback_interval_steps` kept its exemption from retention
91
+ forever and the store grew without bound. Only the newest N are protected
92
+ now; past that they become ordinary snapshots again and retention prunes them
93
+ in its own order. `0` disables rollback protection, as it already did for the
94
+ interval.
95
+ - **`save_final()` uploaded before the merge finished.** `merge_now()` queued a
96
+ merge on another thread and returned; `sync_now()` waited only for the save
97
+ thread, so the final merged snapshot — the one every earlier checkpoint had
98
+ just been folded into — could still be being written when the upload listed
99
+ the files. `merge_now()` blocks until the merge is done and reports its
100
+ failure.
101
+ - **Deletions never reached the remote.** Retention and the merger deleted
102
+ locally and stopped there, so `keep_last` bounded the SSD while the bucket
103
+ kept every pack the run ever wrote. Deleted keys are now queued and removed
104
+ on the syncer's next pass. Only keys this process deleted: listing the remote
105
+ and removing whatever is not local would erase a backup the first time a
106
+ fresh machine pointed at it.
107
+ - **A typo in `save_dtype` was silently ignored.** `DType::from_str` mapped
108
+ anything it did not recognise to "do not cast", so `save_dtype="bfloat"`
109
+ produced full-precision checkpoints and said nothing. It is `DType::parse`
110
+ now and returns an error.
111
+ - **`save_dtype` skipped float64, float16 and bfloat16 sources.**
112
+ `is_castable_float` declared them castable and `cast_tensor` had no arm for
113
+ them, so those tensors were stored untouched at the size the setting was
114
+ chosen to avoid. Every float dtype now converts to every other, through fp32.
115
+ - **An unknown dtype on load became float32.** `_DTYPE_MAP.get(dtype,
116
+ torch.float32)` reinterpreted the bytes under a dtype that was not theirs
117
+ whenever the element sizes divided — right shape, plausible numbers, no
118
+ relationship to what was saved. It raises now, and `complex64`/`complex128`,
119
+ which the byte path had always been able to *save*, can finally be read back.
120
+
121
+ ### Fixed — S3 backend
122
+
123
+ - 404 was recognised by looking for the substring `"404"` in an error message,
124
+ which also matched any object whose key contained those digits. The status is
125
+ carried in the error type now.
126
+ - No retries at all: one 503, one reset connection or one DNS hiccup failed a
127
+ checkpoint upload. Three attempts with backoff, for 5xx, 429 and transport
128
+ errors only — a bad signature or a missing bucket still fails immediately.
129
+ - `ListObjectsV2` keys were used without unescaping XML entities, so a key
130
+ containing `&` came back as `a&b`. Every later read or delete used a key
131
+ that does not exist: the object was unreachable and still billed.
132
+ - `get_range` fell through to the trait default, which downloads the whole
133
+ object and slices it — the entire checkpoint over the network, once per
134
+ tensor. It sends a `Range` header now.
135
+
136
+ ### Fixed — packaging
137
+
138
+ - **`requirements.txt` was UTF-16LE**, so `pip install -r requirements.txt`
139
+ failed to parse it. UTF-8 now.
140
+ - **Wheels carried `__pycache__`.** Every 0.0.4 wheel shipped cp314 bytecode
141
+ from a working tree where the tests had already run: harmless, and six wheels
142
+ for six interpreters holding one interpreter's `.pyc` files.
143
+ - **The `Development Status` classifier claimed `5 - Production/Stable`** on a
144
+ 0.0.x release. It is `4 - Beta`.
145
+
146
+ ### Added
147
+
148
+ - **A source distribution on PyPI.** Wheels are still Linux x86_64 only, so
149
+ until now `pip install moonclip` on macOS, Windows or aarch64 answered "no
150
+ matching distribution found" — which reads as "this package does not exist"
151
+ rather than "there is no wheel for your platform". pip can build from the
152
+ sdist instead, given a Rust toolchain.
153
+
154
+ ### Changed
155
+
156
+ - `merge_now()` blocks and returns a result instead of returning immediately.
157
+ - `DType::from_str` is `DType::parse` and returns `Result<DType>`.
158
+ - `RemoteSyncer::new` takes a `PendingDeletes` queue.
159
+ - `DeltaMerger::new` is crate-internal: a merger has to share the coordinator's
160
+ in-flight registry or it deletes snapshots out from under readers, and there
161
+ is no way to hand one in from outside.
162
+ - `is_castable_float` includes float64 again — this time because the cast
163
+ exists.
164
+
165
+ ## 0.0.4 — 2026-08-17
166
+
167
+ First release on PyPI (`pip install moonclip`) and crates.io. Until now the only
168
+ way in was `pip install git+…`, so the entries below are written for whoever did
169
+ that — and the first one is a reason to move.
170
+
171
+ ### Fixed
172
+
173
+ - **`keep_last` erased the history instead of pruning it.** The total cap could
174
+ only remove a whole snapshot *group* — a full plus every delta hanging off it.
175
+ Since every delta is computed against the last full, a normal run has one
176
+ group for thousands of steps, so any `keep_last` smaller than that group did
177
+ not prune: it emptied the store. Found on real hardware with `keep_last: 2`,
178
+ three checkpoints delivered and nothing left after shutdown. The cap now drops
179
+ the oldest *delta*, one at a time; a full survives as long as some delta needs
180
+ it as a base, which means the oldest surviving step can be older than the cap
181
+ suggests. That is the honest answer instead of an unreadable checkpoint.
182
+ - **Merged snapshots were not recoverable.** `do_full_merge` wrote individual
183
+ files with no pack descriptor, so a snapshot produced by the merger could not
184
+ be recovered at startup — and the merge is exactly the moment when a run's
185
+ whole history is concentrated into one snapshot.
186
+ - **Loading a multi-rank snapshot could read another rank's shard.** The rank was
187
+ inferred from the tensor name, and under sharding every rank uses the same
188
+ names. It is passed explicitly now.
189
+ - **`print_stats()` could take down the process.** It drew a rule with U+2500,
190
+ which raises `UnicodeEncodeError` on a cp1252 console: a reporting call
191
+ interrupted training that was checkpointing perfectly well.
192
+ - **`merge_stride` did not do what its name says**, and the chain-depth limit was
193
+ checked after the stride, so a stride larger than `max_chain_depth` never hit
194
+ the ceiling.
195
+ - Type stubs were out of sync with the code (`flush` missing from
196
+ `CheckpointManager`, `keep_base_in_memory` undeclared) — which counts double
197
+ now that the package ships `py.typed`.
198
+
199
+ ### Added
200
+
201
+ - **A private thread pool**, sized `cores / LOCAL_WORLD_SIZE`, with
202
+ `MOONCLIP_THREADS` overriding it. Previously every parallel pass used rayon's
203
+ global pool at one thread per core, so eight ranks on a 128-core node ran 128
204
+ threads each and competed with the application's own rayon. This is hygiene
205
+ rather than speed: capping the threads to the per-process quota measured
206
+ *slightly slower* on 8× RTX 5060 Ti (11.6 s against 10.6 s per handoff), and
207
+ the time turned out not to be inside this crate at all.
208
+ - `MOONCLIP_PROFILE=1` now also reports how long a save waited for the previous
209
+ one to drain. Moonclip keeps one write in flight, so from outside that wait is
210
+ indistinguishable from a slow copy.
211
+ - `py.typed`, so installed type checkers honour the shipped stubs.
212
+
213
+ ### Known limits
214
+
215
+ - **Wheels are Linux x86_64 (manylinux_2_28) only**, CPython 3.9-3.14. Other
216
+ platforms have to build from source, which needs a Rust toolchain.
217
+ - **The speedup is 1.6-3.0×, not the 10-50× an early estimate suggested.** That
218
+ number was projected before anything was measured. On dense pre-training with
219
+ Adam every parameter changes at every step, so there is nothing to skip: what
220
+ is left is 1.6-3.0× faster than `torch.save` and 1.9× smaller. The 6-10× size
221
+ reduction remains plausible on fine-tuning, LoRA and adapters, where most
222
+ tensors are identical between snapshots — it has not been measured yet.
223
+ - Pack files written before 0.0.3 have no magic bytes: they stay readable through
224
+ the manifest, but cannot be recovered without it.