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/__init__.py +59 -0
- gradientmail/adapters/__init__.py +174 -0
- gradientmail/adapters/mlx.py +252 -0
- gradientmail/adapters/torch.py +205 -0
- gradientmail/algorithms/__init__.py +279 -0
- gradientmail/algorithms/diloco.py +521 -0
- gradientmail/clock.py +22 -0
- gradientmail/compression.py +80 -0
- gradientmail/events.py +478 -0
- gradientmail/learner.py +891 -0
- gradientmail/lease.py +44 -0
- gradientmail/mailbox/__init__.py +57 -0
- gradientmail/mailbox/memory.py +39 -0
- gradientmail/mailbox/s3.py +210 -0
- gradientmail/patterns/__init__.py +4 -0
- gradientmail/patterns/barrier.py +67 -0
- gradientmail/patterns/shared_queue.py +104 -0
- gradientmail/protocol.py +839 -0
- gradientmail/serialization.py +241 -0
- gradientmail/session.py +195 -0
- gradientmail/syncer.py +528 -0
- gradientmail-0.0.1.dist-info/METADATA +142 -0
- gradientmail-0.0.1.dist-info/RECORD +25 -0
- gradientmail-0.0.1.dist-info/WHEEL +4 -0
- gradientmail-0.0.1.dist-info/licenses/LICENSE +21 -0
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
|
+

|
|
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
|
+
```
|