gradientmail 0.0.1__py3-none-any.whl

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.
gradientmail/syncer.py ADDED
@@ -0,0 +1,528 @@
1
+ """The syncer: collects a round's snapshots, closes the fragment and commits the round.
2
+
3
+ Every node runs one, hosted by ``Session`` next to its learner; the node that closes a round
4
+ is elected through the mailbox. Round ``r`` targets fragment ``r mod P`` at global step
5
+ ``(r div P)·H + ⌊((r mod P) + 1)·H / P⌋``, so every fragment is synchronized once per ``H``
6
+ steps. The round record is the commit point: a node rebuilds its state from the newest record
7
+ of every fragment, and two nodes that close the same round are resolved at the record write.
8
+ Timings are durations on this process's monotonic clock plus the step durations learners
9
+ report, never wall clocks compared across machines.
10
+
11
+ Election
12
+ --------
13
+ Round ``r`` is closed by the node that created ``claims/{r}/001``, a create-once object naming
14
+ the claimant. The node that commits ``r`` claims ``r + 1`` in the same breath, before anyone
15
+ else can have seen the record, so the closer stays put while it lives and a fresh run's first
16
+ claimant keeps the role. A node that finds a claim by someone else follows: it polls for the
17
+ round record and continues from it. It creates the next claim and closes the round itself as
18
+ soon as the claimant's lease is gone or stale (``lease`` module: released when the claimant
19
+ closed, unrenewed since it crashed), or, for a claimant that is live but silent, once no record
20
+ has appeared within a timeout measured on its own clock (three times the round duration the
21
+ last record's estimates predict, never less than thirty poll intervals). The leases a follower
22
+ observes carry into its ``close``, so a lease first met while following is not trusted afresh
23
+ at takeover. A node that restarts finds its own claim and resumes. Claims only avoid redundant
24
+ work: whoever commits
25
+ first wins, and the other reads the committed record back (``Superseded``) and continues from
26
+ it. A follower keeps no tensors in memory; once elected it reloads the fragments from the
27
+ records (``recover``), so leadership changes cost one reload and steady state costs followers
28
+ one listing per round.
29
+
30
+ Quorum
31
+ ------
32
+ A round closes once ``quorum_size`` snapshots for it are accepted, or once every live learner
33
+ has reported and at least one snapshot is accepted. A snapshot is accepted iff its ``steps``
34
+ are positive, its schema digest is the run's, its ``base_version`` is the fragment's current
35
+ version, its payload is present and hashes to the record and its tensors match the fragment's
36
+ delta schema (the parameters in the algorithm's delta dtype, with their scales when it is
37
+ quantized); otherwise it stays in
38
+ the round record as rejected (``zero_steps``, ``schema``,
39
+ ``stale``, ``missing``, ``corrupt``) with weight 0, the paper's treatment of learners outside
40
+ the round. A live learner is never ``stale``: it advances ``rounds_seen`` only after aligning
41
+ with every fragment it fetched, and ``in_flight ≤ P``; a ``stale`` snapshot comes from a learner
42
+ that skipped a swept payload and published before aligning with the version its next poll listed.
43
+ Liveness is the lease every learner renews while it runs (``lease`` module): a learner whose
44
+ lease has not changed for ``lease_timeout`` seconds on this node's clock, or whose lease is
45
+ gone, is not waited for. Learners therefore join and leave at will, and a quorum above the
46
+ number of live learners closes with the live ones instead of waiting forever; a crashed learner
47
+ holds up the rounds it would have taken part in for one timeout, a learner that closes releases
48
+ its lease at once. Snapshots and leases are polled every ``poll_interval``, one LIST each, and
49
+ payloads are fetched as their snapshots appear, overlapping the wait.
50
+
51
+ Grace
52
+ -----
53
+ After quorum the closer keeps collecting for
54
+ ``min(0.5 · max(ahead·ξ_step - (ξ_quorum + ξ_sync), 0), ξ_round)`` seconds, ``ξ_round`` being the
55
+ round's gap of steps times the step duration learners report and ``ahead`` the steps a learner
56
+ trains before it would stall, ``in_flight`` rounds of them or the algorithm's ``overlap_steps``
57
+ when that is less: half the slack those leave, capped at one round of training. The three
58
+ estimates are EMAs (decay 0.1) of that step duration, taken from the snapshots, of the time
59
+ from round start to quorum and of the time from closing to a durable commit. They travel
60
+ in every round record, so the node that closes the next round continues them; the sync
61
+ estimate lags one round when the closer changes, since a commit's duration is known only to
62
+ the node that made it. The window is zero while any estimate is missing (the first round of a
63
+ run) and ends early once every live learner has reported. With ``in_flight`` 1 the slack is
64
+ negative, since reaching quorum alone takes about a round, and the window stays zero: rounds
65
+ close at quorum and a learner late for a round is left out of it, though its next snapshot
66
+ carries the steps anyway. ``in_flight`` 2 leaves about half a round. The realized
67
+ ``quorum_seconds`` and ``grace_seconds`` are in every round record. A snapshot that arrives
68
+ after the close is not counted and is swept with the round.
69
+
70
+ Recovery
71
+ --------
72
+ ``recover`` lists ``rounds/`` and takes the newest record per fragment: its theta payload is
73
+ the anchor and its outer payload holds the fp32 momentum and, when the published tensor is not
74
+ the master (F16 and BF16 schemas, commits that carry an update), the fp32 master; a fragment
75
+ never committed starts from the manifest's Θ⁰ with zero momentum. The
76
+ next round is the one after the last record and the timing estimates are the last record's.
77
+ Nothing else is persisted, so a crash
78
+ before the record write recomputes the round from the snapshots still in the store and a crash
79
+ after it continues with the next round; the commit sequence is the same either way, except
80
+ that a snapshot which landed in between may now be included. A round that fails for any other
81
+ reason is reported (``RoundFailed``) and retried the same way, from the store, one poll
82
+ later. ``run`` raises ``RunConfigMismatch`` when the manifest was created with another
83
+ algorithm configuration. A ``MailboxUnavailable`` is retried indefinitely with backoff. No
84
+ ``asyncio.timeout`` wraps a store call, because a cancelled ``to_thread`` would leak its
85
+ thread; botocore's own timeouts bound them.
86
+
87
+ Garbage collection
88
+ ------------------
89
+ After committing round ``r`` the closer deletes ``snapshots/{r}/``, ``blobs/snapshots/{r}/``
90
+ and ``claims/{r}/``, sweeps round ``r - 1`` again (for snapshots that landed after its close,
91
+ for claims created after its sweep, and for a predecessor that crashed between commit and
92
+ sweep) and deletes ``blobs/rounds/{r - 2P}/``, which also removes the payloads a superseded
93
+ closer left behind. Two cycles of theta and outer payloads are kept so that the newest version
94
+ of every fragment, at most ``P`` rounds old, always survives and a learner has a cycle to fetch
95
+ a payload whose record it listed. Round records, the manifest and the init payloads are never
96
+ deleted; ``rounds/`` is the tape of the run.
97
+ """
98
+
99
+ import asyncio
100
+ import math
101
+ import time
102
+ from collections.abc import Mapping
103
+ from dataclasses import dataclass
104
+ from statistics import fmean
105
+
106
+ from gradientmail.adapters import Backend, default_backend
107
+ from gradientmail.algorithms import (
108
+ Aggregator,
109
+ Algorithm,
110
+ Contribution,
111
+ Estimates,
112
+ FragmentState,
113
+ RoundSchedule,
114
+ layout,
115
+ )
116
+ from gradientmail.algorithms.diloco import grace_window
117
+ from gradientmail.clock import Clock, MonotonicClock
118
+ from gradientmail.compression import Compression
119
+ from gradientmail.events import (
120
+ Callback,
121
+ FollowingRound,
122
+ Recovered,
123
+ RecoveryFailed,
124
+ RoundClaimed,
125
+ RoundClosed,
126
+ RoundCommitted,
127
+ RoundFailed,
128
+ RoundSuperseded,
129
+ TookOverRound,
130
+ log_events,
131
+ )
132
+ from gradientmail.lease import Liveness
133
+ from gradientmail.mailbox import Mailbox
134
+ from gradientmail.protocol import (
135
+ Exchange,
136
+ Manifest,
137
+ PayloadCorrupt,
138
+ PayloadRef,
139
+ ProtocolError,
140
+ Rejection,
141
+ RoundRecord,
142
+ RoundRecordDraft,
143
+ RunConfigMismatch,
144
+ Snapshot,
145
+ Superseded,
146
+ )
147
+ from gradientmail.serialization import PayloadDType, Schema, SchemaMismatch, TensorBytes, validate
148
+
149
+ __all__ = ["Following", "Progress", "Syncer", "SyncerState"]
150
+
151
+
152
+ @dataclass(frozen=True)
153
+ class SyncerState:
154
+ """A closer's knowledge between rounds.
155
+
156
+ ``round`` is the next round to run, ``fragments`` the last committed state of every
157
+ fragment and ``estimates`` the timing EMAs behind the grace window.
158
+ """
159
+
160
+ round: int
161
+ fragments: tuple[FragmentState, ...]
162
+ estimates: Estimates
163
+
164
+
165
+ @dataclass(frozen=True)
166
+ class Following:
167
+ """A follower's knowledge: the next round, and how long to wait for its record before
168
+ claiming it, unless the claimant's lease ends first."""
169
+
170
+ round: int
171
+ timeout: float
172
+
173
+
174
+ type Progress = SyncerState | Following
175
+
176
+
177
+ class Syncer:
178
+ """Drives a run's rounds against a mailbox; ``run`` is the whole loop, ``run_round`` one step.
179
+
180
+ Everything the manifest fixes (schema, plan, schedule, one aggregator per fragment) is read
181
+ once on first use; the manifest is a create-once object. The outer step runs in ``backend``,
182
+ by default the installed one (torch, then MLX); constructing a syncer with neither installed
183
+ raises ``MissingExtra``. Round records are byte-identical across restarts on one backend;
184
+ another backend may differ in the last bit. ``compression`` is the codec this node's
185
+ commits are stored with. ``on_event`` receives every event the syncer reports (``events``
186
+ module), on the thread running ``run``.
187
+ """
188
+
189
+ def __init__(
190
+ self,
191
+ *,
192
+ algorithm: Algorithm,
193
+ mailbox: Mailbox,
194
+ learner_id: str,
195
+ backend: Backend | None = None,
196
+ clock: Clock | None = None,
197
+ poll_interval: float = 0.5,
198
+ lease_timeout: float = 120.0,
199
+ compression: Compression = "off",
200
+ on_event: Callback = log_events,
201
+ ) -> None:
202
+ self._algorithm = algorithm
203
+ self._learner_id = learner_id
204
+ self._backend = backend if backend is not None else default_backend()
205
+ self._clock = clock if clock is not None else MonotonicClock()
206
+ self._exchange = Exchange(mailbox, clock=self._clock, compression=compression)
207
+ self._poll_interval = poll_interval
208
+ self._on_event = on_event
209
+ self._liveness = Liveness(lease_timeout)
210
+ self._run: _Run | None = None
211
+ self._main: asyncio.Task[None] | None = None
212
+ self._stopped = False
213
+
214
+ async def run(self, *, until_round: int | None = None) -> None:
215
+ """Wait for the manifest, check the configuration, recover and run round after round.
216
+
217
+ Returns once round ``until_round`` is committed; with ``None`` it runs until cancelled
218
+ or ``stop()``. Raises ``RunConfigMismatch`` when the run was created with another
219
+ algorithm configuration. A round that fails is reported and retried as after a restart:
220
+ the claim this node holds, if any, is found again and the round recomputed.
221
+ """
222
+ self._main = asyncio.current_task()
223
+ if self._stopped:
224
+ return
225
+ while (manifest := await self._exchange.manifest()) is None:
226
+ await self._clock.sleep(self._poll_interval)
227
+ expected = self._algorithm.to_json()
228
+ if dict(manifest.algorithm) != dict(expected):
229
+ raise RunConfigMismatch(expected, manifest.algorithm)
230
+ progress: Progress | None = None
231
+ while True:
232
+ try:
233
+ if progress is None:
234
+ progress = await self.recover()
235
+ if until_round is not None and progress.round > until_round:
236
+ return
237
+ progress = await self.run_round(progress)
238
+ except Exception as exc:
239
+ self._on_event(
240
+ RecoveryFailed(self._learner_id, exc, self._poll_interval)
241
+ if progress is None
242
+ else RoundFailed(self._learner_id, progress.round, exc, self._poll_interval)
243
+ )
244
+ await self._clock.sleep(self._poll_interval)
245
+ if progress is not None:
246
+ progress = Following(progress.round, self._timeout_of(progress))
247
+
248
+ def stop(self) -> None:
249
+ """Cancel ``run`` from any thread; a ``run`` that has not started yet returns at once."""
250
+ self._stopped = True
251
+ if self._main is not None and not self._main.done():
252
+ loop = self._main.get_loop()
253
+ if not loop.is_closed():
254
+ loop.call_soon_threadsafe(self._main.cancel)
255
+
256
+ async def recover(self) -> SyncerState:
257
+ """Rebuild a closer's state from the store.
258
+
259
+ Every fragment comes from its newest round record (theta and outer payloads), or from the
260
+ manifest's Θ⁰ with zero momentum when it was never committed. The next round is the one
261
+ after the last record; the timing estimates are that record's.
262
+ """
263
+
264
+ async def required(ref: PayloadRef) -> Mapping[str, TensorBytes]:
265
+ tensors = await self._exchange.fetch(ref)
266
+ if tensors is None:
267
+ raise ProtocolError(f"{ref.key} is referenced by a live record but is gone")
268
+ return tensors
269
+
270
+ async def fragment_state(fragment: int) -> FragmentState:
271
+ record = newest.get(fragment)
272
+ if record is None:
273
+ momentum = {
274
+ f"momentum.{name}": TensorBytes(
275
+ "F32", shapes[name], bytes(4 * math.prod(shapes[name]))
276
+ )
277
+ for name in run.manifest.plan[fragment]
278
+ }
279
+ return FragmentState(0, await required(run.manifest.init[fragment]), momentum)
280
+ return FragmentState(
281
+ record.version, await required(record.theta), await required(record.outer)
282
+ )
283
+
284
+ run = await self._resolved()
285
+ records = await self._exchange.rounds_after(-1)
286
+ # Records come in round order, so the last one per fragment wins.
287
+ newest = {record.fragment: record for record in records}
288
+ shapes = {spec.name: spec.shape for spec in run.manifest.schema.parameters}
289
+ count = len(run.manifest.plan)
290
+ async with asyncio.TaskGroup() as group:
291
+ loads = [group.create_task(fragment_state(p)) for p in range(count)]
292
+ last = records[-1] if records else None
293
+ state = SyncerState(
294
+ round=0 if last is None else last.round + 1,
295
+ fragments=tuple(load.result() for load in loads),
296
+ estimates=Estimates() if last is None else last.estimates,
297
+ )
298
+ self._on_event(Recovered(self._learner_id, state.round))
299
+ return state
300
+
301
+ async def run_round(self, progress: Progress) -> Progress:
302
+ """Advance one round: close it when elected, otherwise follow it to its record."""
303
+ committed = await self._exchange.rounds_after(progress.round - 1)
304
+ if committed:
305
+ # Closed already, its claims swept with it; a node arriving now must not reclaim it.
306
+ return Following(committed[0].round + 1, self._timeout(committed[0]))
307
+ if not await self.elected(progress.round):
308
+ return await self.follow(progress)
309
+ state = progress if isinstance(progress, SyncerState) else await self.recover()
310
+ if state.round != progress.round:
311
+ # Committed meanwhile by an earlier claimant that was slow rather than dead.
312
+ return state
313
+ return await self.close(state)
314
+
315
+ async def elected(self, round: int) -> bool:
316
+ """Whether this node closes ``round``: it holds a claim, or it makes the first one."""
317
+ claimants = await self._exchange.claims(round)
318
+ if claimants:
319
+ return self._learner_id in claimants
320
+ claimed = await self._exchange.claim(round, 1, self._learner_id)
321
+ if claimed:
322
+ self._on_event(RoundClaimed(self._learner_id, round))
323
+ return claimed
324
+
325
+ async def follow(self, progress: Progress) -> Progress:
326
+ """Wait for the round's record; take the next claim and close the round once the
327
+ claimant's lease is gone or stale, or after the timeout."""
328
+ round = progress.round
329
+ timeout = self._timeout_of(progress)
330
+ claimants = await self._exchange.claims(round)
331
+ self._on_event(FollowingRound(self._learner_id, round, tuple(claimants), timeout))
332
+ seen_at = self._clock.now()
333
+ while True:
334
+ records = await self._exchange.rounds_after(round - 1)
335
+ if records:
336
+ return Following(records[0].round + 1, self._timeout(records[0]))
337
+ leases = await self._exchange.leases()
338
+ now = self._clock.now()
339
+ self._liveness = self._liveness.observed(leases, now)
340
+ gone = bool(claimants) and claimants[-1] not in self._liveness.live(now)
341
+ if gone or now - seen_at >= timeout:
342
+ if await self._exchange.claim(round, len(claimants) + 1, self._learner_id):
343
+ self._on_event(
344
+ TookOverRound(
345
+ self._learner_id,
346
+ round,
347
+ tuple(claimants),
348
+ "lease_gone" if gone else "no_record",
349
+ timeout,
350
+ )
351
+ )
352
+ state = await self.recover()
353
+ return state if state.round != round else await self.close(state)
354
+ claimants = await self._exchange.claims(round)
355
+ seen_at = self._clock.now()
356
+ await self._clock.sleep(self._poll_interval)
357
+
358
+ async def close(self, state: SyncerState) -> Progress:
359
+ """Close round ``state.round`` through its commit and return the state for the next one.
360
+
361
+ Collects snapshots until quorum, or until every live learner has reported, keeps
362
+ collecting through the grace window unless every live learner has reported, closes the
363
+ fragment, commits, claims the next round and sweeps. When another node committed the
364
+ round first, its record is adopted and this node follows from there.
365
+ """
366
+
367
+ async def judge(snapshot: Snapshot) -> Contribution | Rejection:
368
+ if snapshot.steps == 0:
369
+ return "zero_steps"
370
+ if snapshot.schema_digest != run.digest:
371
+ return "schema"
372
+ if snapshot.base_version != current.version:
373
+ return "stale"
374
+ try:
375
+ tensors = await self._exchange.fetch(snapshot.payload)
376
+ except PayloadCorrupt:
377
+ return "corrupt"
378
+ if tensors is None:
379
+ return "missing"
380
+ try:
381
+ validate(deltas, tensors, [spec.name for spec in deltas.parameters])
382
+ except SchemaMismatch:
383
+ return "schema"
384
+ return Contribution(
385
+ snapshot.learner,
386
+ snapshot.local_step,
387
+ snapshot.steps,
388
+ snapshot.tokens,
389
+ snapshot.step_seconds,
390
+ snapshot.final,
391
+ tensors,
392
+ )
393
+
394
+ async def poll() -> None:
395
+ leases = await self._exchange.leases()
396
+ self._liveness = self._liveness.observed(leases, self._clock.now())
397
+ snapshots = await self._exchange.snapshots(round)
398
+ new = tuple(s for s in snapshots if s.learner not in verdicts)
399
+ async with asyncio.TaskGroup() as group:
400
+ judged = [(s.learner, group.create_task(judge(s))) for s in new]
401
+ verdicts.update((learner, task.result()) for learner, task in judged)
402
+
403
+ def accepted() -> tuple[Contribution, ...]:
404
+ contributions = (v for v in verdicts.values() if isinstance(v, Contribution))
405
+ return tuple(sorted(contributions, key=lambda c: c.learner))
406
+
407
+ def reported() -> bool:
408
+ """Every live learner has a snapshot in the round, accepted or rejected."""
409
+ return self._liveness.live(self._clock.now()) <= verdicts.keys()
410
+
411
+ def closable() -> bool:
412
+ return len(accepted()) >= self._algorithm.quorum() or (
413
+ len(accepted()) >= 1 and reported()
414
+ )
415
+
416
+ run = await self._resolved()
417
+ round = state.round
418
+ fragment = run.schedule.fragment_of(round)
419
+ current = state.fragments[fragment]
420
+ deltas = run.deltas[fragment]
421
+ verdicts: dict[str, Contribution | Rejection] = {}
422
+
423
+ started = self._clock.now()
424
+ await poll()
425
+ while not closable():
426
+ await self._clock.sleep(self._poll_interval)
427
+ await poll()
428
+ quorum_at = self._clock.now()
429
+
430
+ deadline = quorum_at + grace_window(
431
+ state.estimates,
432
+ run.schedule.in_flight,
433
+ run.schedule.gap(round),
434
+ run.schedule.overlap_steps,
435
+ )
436
+ while (now := self._clock.now()) < deadline and not reported():
437
+ await self._clock.sleep(min(self._poll_interval, deadline - now))
438
+ await poll()
439
+ grace_ended = self._clock.now()
440
+
441
+ contributions = accepted()
442
+ result = run.aggregators[fragment].close_round(current, contributions)
443
+ updated = result.state
444
+ estimates = state.estimates.updated(
445
+ step=fmean(c.step_seconds for c in contributions), quorum=quorum_at - started
446
+ )
447
+ draft = RoundRecordDraft(
448
+ round=round,
449
+ step=run.schedule.step_of(round),
450
+ fragment=fragment,
451
+ version=updated.version,
452
+ participants=result.participants,
453
+ rejected=tuple(
454
+ sorted((learner, why) for learner, why in verdicts.items() if isinstance(why, str))
455
+ ),
456
+ cosine=result.cosine,
457
+ quorum_seconds=quorum_at - started,
458
+ grace_seconds=grace_ended - quorum_at,
459
+ estimates=estimates,
460
+ )
461
+ self._on_event(RoundClosed(self._learner_id, draft))
462
+ committing = time.perf_counter()
463
+ outcome = await self._exchange.commit(draft, updated.theta, updated.outer, result.update)
464
+ if isinstance(outcome, Superseded):
465
+ self._on_event(RoundSuperseded(self._learner_id, outcome.record))
466
+ return Following(outcome.record.round + 1, self._timeout(outcome.record))
467
+ sync_seconds = self._clock.now() - grace_ended
468
+ self._on_event(
469
+ RoundCommitted(self._learner_id, outcome.record, time.perf_counter() - committing)
470
+ )
471
+
472
+ await self._exchange.claim(round + 1, 1, self._learner_id)
473
+ count = len(state.fragments)
474
+ for swept in (round, round - 1):
475
+ if swept >= 0:
476
+ await self._exchange.sweep_snapshots(swept)
477
+ await self._exchange.sweep_blobs(round - 2 * count)
478
+ return SyncerState(
479
+ round=round + 1,
480
+ fragments=(*state.fragments[:fragment], updated, *state.fragments[fragment + 1 :]),
481
+ estimates=estimates.updated(sync=sync_seconds),
482
+ )
483
+
484
+ async def _resolved(self) -> "_Run":
485
+ if self._run is None:
486
+ manifest = await self._exchange.manifest()
487
+ if manifest is None:
488
+ raise ProtocolError("manifest.json does not exist: no learner has created the run")
489
+ schema, plan = manifest.schema, manifest.plan
490
+ specs = {spec.name: spec for spec in schema.parameters}
491
+ self._run = _Run(
492
+ manifest=manifest,
493
+ digest=schema.digest(),
494
+ deltas=tuple(
495
+ layout(
496
+ Schema(tuple(specs[name] for name in names)), self._algorithm.delta_dtype
497
+ )
498
+ for names in plan
499
+ ),
500
+ schedule=self._algorithm.schedule(len(plan)),
501
+ aggregators=tuple(
502
+ self._algorithm.aggregator(schema, names, self._backend) for names in plan
503
+ ),
504
+ )
505
+ return self._run
506
+
507
+ def _timeout(self, record: RoundRecord) -> float:
508
+ """How long to wait for the round after ``record`` before taking it over."""
509
+ return record.estimates.timeout(self._poll_interval)
510
+
511
+ def _timeout_of(self, progress: Progress) -> float:
512
+ return (
513
+ progress.timeout
514
+ if isinstance(progress, Following)
515
+ else progress.estimates.timeout(self._poll_interval)
516
+ )
517
+
518
+
519
+ @dataclass(frozen=True)
520
+ class _Run:
521
+ """What the manifest fixes for the life of the run; ``deltas`` is what a snapshot of each
522
+ fragment must hold."""
523
+
524
+ manifest: Manifest
525
+ digest: str
526
+ deltas: tuple[Schema[PayloadDType], ...]
527
+ schedule: RoundSchedule
528
+ aggregators: tuple[Aggregator, ...]
@@ -0,0 +1,142 @@
1
+ Metadata-Version: 2.4
2
+ Name: gradientmail
3
+ Version: 0.0.1
4
+ Summary: Distributed training through asynchronous message exchange
5
+ Author: Gabriel Francisco
6
+ Author-email: Gabriel Francisco <gabfssilva@gmail.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Requires-Dist: safetensors>=0.8,<1
10
+ Requires-Dist: jax ; extra == 'jax'
11
+ Requires-Dist: keras ; extra == 'keras'
12
+ Requires-Dist: mlx ; extra == 'mlx'
13
+ Requires-Dist: boto3>=1.36 ; extra == 's3'
14
+ Requires-Dist: tensorflow ; extra == 'tensorflow'
15
+ Requires-Dist: torch ; extra == 'torch'
16
+ Requires-Dist: zstandard>=0.23 ; extra == 'zstd'
17
+ Requires-Python: >=3.12
18
+ Project-URL: Homepage, https://ppgsi-lab.github.io/gradientmail/
19
+ Project-URL: Documentation, https://ppgsi-lab.github.io/gradientmail/docs/guide/
20
+ Project-URL: Repository, https://github.com/ppgsi-lab/gradientmail
21
+ Provides-Extra: jax
22
+ Provides-Extra: keras
23
+ Provides-Extra: mlx
24
+ Provides-Extra: s3
25
+ Provides-Extra: tensorflow
26
+ Provides-Extra: torch
27
+ Provides-Extra: zstd
28
+ Description-Content-Type: text/markdown
29
+
30
+ # GradientMail
31
+
32
+ Distributed training with [Decoupled DiLoCo](https://arxiv.org/abs/2604.21428) over a shared mailbox. Nodes exchange parameter fragments through S3, R2, MinIO or Hugging Face Storage Buckets, without P2P connections or a separate coordinator.
33
+
34
+ The cluster is heterogeneous and elastic: nodes with different GPUs and frameworks (PyTorch or MLX) can join an ongoing run, contribute to training for a period and leave. One of the training nodes merges each round and publishes the result. If it fails, another takes over.
35
+
36
+ ## How it works
37
+
38
+ Each learner keeps a local copy of the model and runs its own optimizer. Parameters are split into fragments, synchronized on a staggered schedule. For each fragment, a learner publishes its update: the difference between the committed parameters it last aligned with and the ones it trained.
39
+
40
+ ![Three nodes and a mailbox. Node A (MLX on Apple silicon), node B (PyTorch on an H100) and node C (PyTorch on an RTX 4090) each run a learner and a syncer. Each learner trains fragment k, puts its delta, the anchor minus its parameters, in the mailbox, polls for commits and aligns with them between its steps. Node B's syncer is elected for the round: it lists and gets the round's deltas, merges them with RDA, applies the Nesterov outer step and commits fragment k with the outer state. The other syncers follow it and take over if node B stalls. The mailbox, an S3, R2, MinIO or Hugging Face bucket, holds the deltas and the commits; every arrow is a request to it.](https://ppgsi-lab.github.io/gradientmail/docs/assets/training-flow.svg)
41
+
42
+ An elected learner reads the updates for a round, merges them and applies the outer optimizer. It commits the updated fragment through the mailbox; learners poll for new commits and align their local models with them. All training and merge arithmetic runs on the learners.
43
+
44
+ ## Install
45
+
46
+ Python 3.12+.
47
+
48
+ ```sh
49
+ uv add "gradientmail[s3,torch]"
50
+ # or
51
+ pip install "gradientmail[s3,torch]"
52
+ ```
53
+
54
+ Use `mlx` instead of `torch` for MLX on Apple Silicon.
55
+
56
+ ## Usage
57
+
58
+ Add a `Session` to your existing training loop:
59
+
60
+ ```python
61
+ import gradientmail as gm
62
+
63
+ with gm.Session(
64
+ model,
65
+ algorithm=gm.DiLoCo(sync_every=100, fragments=8),
66
+ mailbox=gm.S3Mailbox(bucket="my-training", prefix="experiments/run-1"),
67
+ ) as session:
68
+ for batch in dataloader:
69
+ loss = train_step(model, optimizer, batch)
70
+ session.step(samples=len(batch))
71
+ ```
72
+
73
+ Run this on each node with the same bucket, prefix, algorithm configuration and parameter schema (names, shapes and dtypes). `model` can be a PyTorch or MLX module. The first node initializes the run; later nodes load its committed parameters before training. Use a new prefix for each run.
74
+
75
+ `sync_every=100, fragments=8` splits the model into up to eight parameter groups, each synchronized every 100 local steps. `step(samples=...)` reports the tokens or samples processed by that optimizer step. Leaving the context waits for the node's rounds in flight to commit, stopping the wait if the run stalls, and sets the model to the newest committed parameters. Steps trained since a fragment's last round are dropped, as in the DiLoCo papers; `flush_on_close=True` publishes them instead, and a round made only of those final contributions commits them without the outer momentum.
76
+
77
+ Training continues during synchronization. Aligning a fragment with its commit replaces local updates made while it was in flight. `overlap_steps=N` on `gm.DiLoCo` bounds that: a node waits once a round it published N steps ago is still not aligned with, and `0` is standard DiLoCo's barrier. `mixing=0.5` keeps that share of the local parameters when aligning, blending them with the commit instead of replacing them, which Streaming DiLoCo found to recover most of what overlapped steps cost.
78
+
79
+ The defaults follow Decoupled DiLoCo. `fragments=1, in_flight=1, overlap_steps=0, merge=gm.WeightedMean()` with `quorum_size` equal to the number of nodes is standard DiLoCo. `partition=gm.Strided()` deals the transformer blocks round the fragments, Streaming DiLoCo's pattern, instead of balancing the fragments by bytes.
80
+
81
+ `quorum_size` sets the target number of contributions per round (default 1). Rounds can close with fewer contributions once every live node has reported. Crashed nodes stop being counted after their lease expires (default 120 seconds).
82
+
83
+ `global_batch_size` fixes the batch the live nodes share. `session.batch_size` is this node's share, the global batch divided by the live nodes and rounded up, for the training loop to size its batches by; it changes as nodes join and leave. Without it, each node's batch is its own and the global batch grows with the node count.
84
+
85
+ `delta_dtype="BF16"` sends each update in bf16, halving an fp32 model's uploads at a rounding of 8 significant bits per element; `"F8_E4M3"` quarters them and `"F4"` takes an eighth, each element scaled by a power of two per tensor or per block of 32, as Streaming DiLoCo sends its outer gradients; by default it keeps each parameter's own dtype. `commit_dtype` publishes each commit's change in the same formats, so a node holding the previous version downloads that instead of the full parameters. `compression="zstd"` on `Session` stores this node's payloads byte-shuffled and zstandard-compressed (the `zstd` extra), about 16% smaller for fp32 and 31% for bf16; every node reads either format, so nodes of one run may differ.
86
+
87
+ `S3Mailbox` uses boto3 credentials. For other S3-compatible services, pass `endpoint_url=`; Hugging Face Storage Buckets also need `request_checksums="when_required"`.
88
+
89
+ For evaluation, `gm.align(model, mailbox=...)` loads the committed model without joining the run. GradientMail does not save your inner optimizer state; checkpoint it separately.
90
+
91
+ ## Examples
92
+
93
+ Run two synthetic learners locally, with PyTorch or MLX installed:
94
+
95
+ ```sh
96
+ uv run python examples/synthetic.py
97
+ ```
98
+
99
+ For a PyTorch/MLX run across two machines, use [torch_learner.py](https://github.com/ppgsi-lab/gradientmail/blob/main/examples/torch_learner.py) and [mlx_learner.py](https://github.com/ppgsi-lab/gradientmail/blob/main/examples/mlx_learner.py). The PyTorch example includes the MinIO setup and commands.
100
+
101
+ ## Further reading
102
+
103
+ - [Guide](https://ppgsi-lab.github.io/gradientmail/docs/guide/)
104
+
105
+ A collection of explanations for understanding distributed training.
106
+
107
+ - [Shared Queue](https://ppgsi-lab.github.io/gradientmail/docs/patterns/00-shared-queue.html)
108
+
109
+ Distributing work across nodes with different processing speeds.
110
+
111
+ - [Barrier](https://ppgsi-lab.github.io/gradientmail/docs/patterns/01-barrier.html)
112
+
113
+ Holding nodes at a named point until a number of them have reached it.
114
+
115
+ - [Posts](https://ppgsi-lab.github.io/gradientmail/docs/posts/)
116
+
117
+ A Pythia-14M run on four rented GPUs, MLX and PyTorch learners in one run, merging deltas with RDA, and implementing a mailbox backend.
118
+
119
+ ## Development
120
+
121
+ From a checkout:
122
+
123
+ ```sh
124
+ uv sync --extra s3 --extra torch
125
+ uv run pytest
126
+ uv run ruff check
127
+ uv run ruff format --check
128
+ uv run pyrefly check
129
+ ```
130
+
131
+ Tests use memory and moto stores by default. Set `GRADIENTMAIL_S3_ENDPOINT` to test against an S3-compatible store. Framework tests skip when their dependencies are absent.
132
+
133
+ ## Citation
134
+
135
+ ```bibtex
136
+ @software{gradientmail,
137
+ author = {dos Santos Silva, Gabriel Francisco},
138
+ title = {GradientMail: Distributed training through asynchronous message exchange},
139
+ year = {2026},
140
+ url = {https://github.com/ppgsi-lab/gradientmail},
141
+ }
142
+ ```