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/learner.py
ADDED
|
@@ -0,0 +1,891 @@
|
|
|
1
|
+
"""The learner: counters and publishing decisions as pure state, plus the two-thread runtime.
|
|
2
|
+
|
|
3
|
+
The training thread owns the model and every counter: ``step()`` advances the state, applies
|
|
4
|
+
the fragment versions the receive loop has queued, and hands one fragment snapshot, with the
|
|
5
|
+
anchor it was trained from, to the publish loop when a round is due; the publish loop
|
|
6
|
+
computes their difference in the backend and publishes that delta. A daemon thread runs the
|
|
7
|
+
receive and publish loops on its own event loop; the two sides meet only through two
|
|
8
|
+
``queue.SimpleQueue``s, so ``step()`` never awaits and never does I/O. It blocks only when
|
|
9
|
+
the schedule bounds ``overlap_steps``, waiting in ``step()`` for the model to align with a
|
|
10
|
+
round it published that many steps ago, and ``close()`` waits for the last ones.
|
|
11
|
+
|
|
12
|
+
A fetched batch is queued only once every payload in it is in hand, so ``rounds_seen`` never
|
|
13
|
+
runs ahead of a fragment that is not yet applied: that is what keeps a live learner's
|
|
14
|
+
snapshots at the syncer's current version.
|
|
15
|
+
|
|
16
|
+
The receive loop also renews this learner's lease and lists everyone's (``lease`` module), so
|
|
17
|
+
``step()`` knows how many learners are live when it judges whether a round is about to close
|
|
18
|
+
and ``batch_size`` can split a global batch among them; ``close()`` releases the lease once the
|
|
19
|
+
last snapshot is out.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import logging
|
|
24
|
+
import queue
|
|
25
|
+
import threading
|
|
26
|
+
import time
|
|
27
|
+
from collections.abc import Callable, Iterator, Mapping, Sequence
|
|
28
|
+
from dataclasses import dataclass, replace
|
|
29
|
+
|
|
30
|
+
from gradientmail.adapters import Adapter, Backend, default_backend
|
|
31
|
+
from gradientmail.algorithms import (
|
|
32
|
+
Algorithm,
|
|
33
|
+
Estimates,
|
|
34
|
+
Participant,
|
|
35
|
+
Precision,
|
|
36
|
+
RoundSchedule,
|
|
37
|
+
decoded,
|
|
38
|
+
encoded,
|
|
39
|
+
)
|
|
40
|
+
from gradientmail.clock import Clock, MonotonicClock
|
|
41
|
+
from gradientmail.compression import Compression
|
|
42
|
+
from gradientmail.events import (
|
|
43
|
+
AwaitingCreator,
|
|
44
|
+
Callback,
|
|
45
|
+
Closed,
|
|
46
|
+
Closing,
|
|
47
|
+
CreatingRun,
|
|
48
|
+
FragmentAligned,
|
|
49
|
+
GaveUp,
|
|
50
|
+
Joined,
|
|
51
|
+
LeaseReleased,
|
|
52
|
+
PollFailed,
|
|
53
|
+
PublishCancelled,
|
|
54
|
+
PublishFailed,
|
|
55
|
+
ReleaseFailed,
|
|
56
|
+
RoundRecorded,
|
|
57
|
+
SnapshotPublished,
|
|
58
|
+
TookOverCreation,
|
|
59
|
+
log_events,
|
|
60
|
+
)
|
|
61
|
+
from gradientmail.lease import Liveness
|
|
62
|
+
from gradientmail.mailbox import Mailbox
|
|
63
|
+
from gradientmail.protocol import (
|
|
64
|
+
Exchange,
|
|
65
|
+
Manifest,
|
|
66
|
+
ManifestDraft,
|
|
67
|
+
PayloadRef,
|
|
68
|
+
ProtocolError,
|
|
69
|
+
RoundRecord,
|
|
70
|
+
RunConfigMismatch,
|
|
71
|
+
SnapshotDraft,
|
|
72
|
+
theta_digest,
|
|
73
|
+
)
|
|
74
|
+
from gradientmail.serialization import DType, Schema, SchemaMismatch, TensorBytes, validate
|
|
75
|
+
|
|
76
|
+
__all__ = [
|
|
77
|
+
"Aligned",
|
|
78
|
+
"Learner",
|
|
79
|
+
"LearnerState",
|
|
80
|
+
"Publishers",
|
|
81
|
+
"advanced",
|
|
82
|
+
"align",
|
|
83
|
+
"applied",
|
|
84
|
+
"due_round",
|
|
85
|
+
"overdue",
|
|
86
|
+
"publishable",
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
log = logging.getLogger(__name__)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True)
|
|
93
|
+
class LearnerState:
|
|
94
|
+
"""Everything the training thread tracks; one value per fragment where it says so.
|
|
95
|
+
|
|
96
|
+
``rounds_seen`` is the newest round whose fragment the model is aligned with,
|
|
97
|
+
``last_published`` the newest round this learner has a snapshot queued for and
|
|
98
|
+
``published`` the ``(round, local_step)`` of its last ``in_flight`` publishes, oldest first.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
local_step: int
|
|
102
|
+
steps: tuple[int, ...]
|
|
103
|
+
tokens: tuple[int, ...]
|
|
104
|
+
applied_version: tuple[int, ...]
|
|
105
|
+
rounds_seen: int
|
|
106
|
+
last_published: int
|
|
107
|
+
published: tuple[tuple[int, int], ...]
|
|
108
|
+
step_seconds: float
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True)
|
|
112
|
+
class Publishers:
|
|
113
|
+
"""The other learners with a snapshot in one round, as the receive loop last listed it."""
|
|
114
|
+
|
|
115
|
+
round: int
|
|
116
|
+
others: frozenset[str]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def advanced(state: LearnerState, samples: int, elapsed: float) -> LearnerState:
|
|
120
|
+
"""One training step of ``samples`` tokens that took ``elapsed`` seconds."""
|
|
121
|
+
return replace(
|
|
122
|
+
state,
|
|
123
|
+
local_step=state.local_step + 1,
|
|
124
|
+
steps=tuple(count + 1 for count in state.steps),
|
|
125
|
+
tokens=tuple(count + samples for count in state.tokens),
|
|
126
|
+
step_seconds=(
|
|
127
|
+
elapsed
|
|
128
|
+
if state.local_step == 0
|
|
129
|
+
else state.step_seconds + _EMA_DECAY * (elapsed - state.step_seconds)
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def applied(
|
|
135
|
+
state: LearnerState,
|
|
136
|
+
round: int,
|
|
137
|
+
fragment: int,
|
|
138
|
+
version: int,
|
|
139
|
+
*,
|
|
140
|
+
participant: Participant | None = None,
|
|
141
|
+
mixing: float = 0.0,
|
|
142
|
+
) -> LearnerState:
|
|
143
|
+
"""``fragment`` now holds ``version`` committed by ``round``; its counters start over.
|
|
144
|
+
|
|
145
|
+
They keep ``mixing`` of the steps and tokens the commit did not incorporate, rounded down:
|
|
146
|
+
those since this learner's snapshot in the round (``participant``), or all of them when it
|
|
147
|
+
took no part. That share of the local progress survives in the blended parameters.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
def with_fragment(values: tuple[int, ...], value: int) -> tuple[int, ...]:
|
|
151
|
+
return (*values[:fragment], value, *values[fragment + 1 :])
|
|
152
|
+
|
|
153
|
+
def kept(counted: int, incorporated: int) -> int:
|
|
154
|
+
return int(mixing * (counted - incorporated))
|
|
155
|
+
|
|
156
|
+
steps, tokens = (0, 0) if participant is None else (participant.steps, participant.tokens)
|
|
157
|
+
return replace(
|
|
158
|
+
state,
|
|
159
|
+
steps=with_fragment(state.steps, kept(state.steps[fragment], steps)),
|
|
160
|
+
tokens=with_fragment(state.tokens, kept(state.tokens[fragment], tokens)),
|
|
161
|
+
applied_version=with_fragment(state.applied_version, version),
|
|
162
|
+
rounds_seen=max(state.rounds_seen, round),
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def publishable(state: LearnerState, schedule: RoundSchedule) -> int | None:
|
|
167
|
+
"""The round this learner publishes next, if it may publish it now.
|
|
168
|
+
|
|
169
|
+
The candidate is the round after both the last published and the last seen. It may be
|
|
170
|
+
published while it is within ``in_flight`` rounds of the last seen and its fragment has
|
|
171
|
+
been trained since it was last overwritten.
|
|
172
|
+
"""
|
|
173
|
+
round = max(state.last_published + 1, state.rounds_seen + 1)
|
|
174
|
+
allowed = (
|
|
175
|
+
round <= state.rounds_seen + schedule.in_flight
|
|
176
|
+
and state.steps[schedule.fragment_of(round)] >= 1
|
|
177
|
+
)
|
|
178
|
+
return round if allowed else None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def due_round(
|
|
182
|
+
state: LearnerState, schedule: RoundSchedule, *, quorum: int, publishers: Publishers
|
|
183
|
+
) -> int | None:
|
|
184
|
+
"""The round to publish now, if any.
|
|
185
|
+
|
|
186
|
+
The publishable round is due on schedule, once the gap of steps has passed since the last
|
|
187
|
+
publish, or by opportunity, once the others' snapshots in it are one short of ``quorum``:
|
|
188
|
+
the round is about to close, and the steps trained so far go in rather than being
|
|
189
|
+
overwritten by its commit. ``quorum`` is the effective one, the configured size or the
|
|
190
|
+
number of live learners, whichever is smaller. Below quorum two, one other snapshot is that
|
|
191
|
+
signal.
|
|
192
|
+
"""
|
|
193
|
+
round = publishable(state, schedule)
|
|
194
|
+
if round is None:
|
|
195
|
+
return None
|
|
196
|
+
last = state.published[-1][1] if state.published else 0
|
|
197
|
+
on_schedule = state.local_step - last >= schedule.gap(round)
|
|
198
|
+
closing = publishers.round == round and len(publishers.others) >= max(quorum - 1, 1)
|
|
199
|
+
return round if on_schedule or closing else None
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def overdue(state: LearnerState, overlap_steps: int) -> bool:
|
|
203
|
+
"""Whether a round this learner published, and is not yet aligned with, went out
|
|
204
|
+
``overlap_steps`` or more steps ago: training may not run further ahead of it."""
|
|
205
|
+
return any(
|
|
206
|
+
round > state.rounds_seen and state.local_step - at >= overlap_steps
|
|
207
|
+
for round, at in state.published
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class Learner:
|
|
212
|
+
"""Wraps a training loop: ``start()`` once, ``step()`` after every optimizer step, ``close()``.
|
|
213
|
+
|
|
214
|
+
``start`` blocks until the model holds the newest committed version of every fragment,
|
|
215
|
+
then starts the communication thread. An operation that fails on that thread is reported
|
|
216
|
+
(``PollFailed``, ``PublishFailed``) and its loop goes on (``communicate``); only the thread's
|
|
217
|
+
own death is raised from the next ``step()``. When the schedule bounds ``overlap_steps``,
|
|
218
|
+
``step()`` waits for the model to align with a round published that many steps ago before
|
|
219
|
+
training on, as standard DiLoCo does at zero; otherwise training goes on and the commit
|
|
220
|
+
overwrites the steps taken meanwhile. The lease is renewed every ``lease_every`` seconds and
|
|
221
|
+
the other learners' leases are presumed lost after ``lease_timeout`` seconds without a
|
|
222
|
+
change.
|
|
223
|
+
|
|
224
|
+
What goes out is ``anchor - θ``, computed in ``backend`` (the installed one by default)
|
|
225
|
+
against the version of the fragment this learner aligned with last, of which it keeps one host
|
|
226
|
+
copy. ``compression`` is the codec its payloads are stored with. ``on_event`` receives every
|
|
227
|
+
event the learner reports (``events`` module), on the thread that produced it.
|
|
228
|
+
"""
|
|
229
|
+
|
|
230
|
+
def __init__(
|
|
231
|
+
self,
|
|
232
|
+
*,
|
|
233
|
+
adapter: Adapter,
|
|
234
|
+
algorithm: Algorithm,
|
|
235
|
+
mailbox: Mailbox,
|
|
236
|
+
learner_id: str,
|
|
237
|
+
backend: Backend | None = None,
|
|
238
|
+
clock: Clock | None = None,
|
|
239
|
+
poll_interval: float = 1.0,
|
|
240
|
+
lease_every: float = 60.0,
|
|
241
|
+
lease_timeout: float = 120.0,
|
|
242
|
+
compression: Compression = "off",
|
|
243
|
+
on_event: Callback = log_events,
|
|
244
|
+
) -> None:
|
|
245
|
+
if lease_timeout <= lease_every:
|
|
246
|
+
raise ValueError(
|
|
247
|
+
f"lease_timeout must exceed lease_every, got {lease_timeout} and {lease_every}"
|
|
248
|
+
)
|
|
249
|
+
self._adapter = adapter
|
|
250
|
+
self._algorithm = algorithm
|
|
251
|
+
self._learner_id = learner_id
|
|
252
|
+
self._backend = backend if backend is not None else default_backend()
|
|
253
|
+
self._clock = clock if clock is not None else MonotonicClock()
|
|
254
|
+
self._poll_interval = poll_interval
|
|
255
|
+
self._lease_every = lease_every
|
|
256
|
+
self._lease_timeout = lease_timeout
|
|
257
|
+
self._exchange = Exchange(mailbox, clock=self._clock, compression=compression)
|
|
258
|
+
self._on_event = on_event
|
|
259
|
+
self._inbox: queue.SimpleQueue[tuple[_Update, ...]] = queue.SimpleQueue()
|
|
260
|
+
self._outbox: queue.SimpleQueue[_Outgoing | None] = queue.SimpleQueue()
|
|
261
|
+
self._run: _Run | None = None
|
|
262
|
+
self._anchors: list[Mapping[str, TensorBytes]] = []
|
|
263
|
+
self._newest: dict[int, tuple[int, Mapping[str, TensorBytes]]] = {}
|
|
264
|
+
self._state = LearnerState(0, (), (), (), -1, -1, (), 0.0)
|
|
265
|
+
self._cursor = -1
|
|
266
|
+
self._publishers = Publishers(-1, frozenset())
|
|
267
|
+
self._liveness = Liveness(lease_timeout)
|
|
268
|
+
self._live: frozenset[str] = frozenset()
|
|
269
|
+
self._estimates = Estimates()
|
|
270
|
+
self._last_step_at = 0.0
|
|
271
|
+
self._thread: threading.Thread | None = None
|
|
272
|
+
self._main: asyncio.Task[None] | None = None
|
|
273
|
+
self._failure: BaseException | None = None
|
|
274
|
+
|
|
275
|
+
@property
|
|
276
|
+
def rounds_seen(self) -> int:
|
|
277
|
+
return self._state.rounds_seen
|
|
278
|
+
|
|
279
|
+
@property
|
|
280
|
+
def batch_size(self) -> int | None:
|
|
281
|
+
"""This learner's share of the global batch, split among the learners live as of the
|
|
282
|
+
last poll and rounded up; ``None`` when the algorithm sets no global batch."""
|
|
283
|
+
global_batch = self._algorithm.global_batch()
|
|
284
|
+
if global_batch is None:
|
|
285
|
+
return None
|
|
286
|
+
live = len(self._live | {self._learner_id})
|
|
287
|
+
return (global_batch + live - 1) // live
|
|
288
|
+
|
|
289
|
+
def start(self) -> None:
|
|
290
|
+
"""Join the run on the calling thread, then start the communication thread."""
|
|
291
|
+
|
|
292
|
+
def communicate() -> None:
|
|
293
|
+
def failure(exc: BaseException) -> BaseException:
|
|
294
|
+
# TaskGroups wrap a lone failure in one group per nesting level; step()
|
|
295
|
+
# should raise the failure itself.
|
|
296
|
+
match exc:
|
|
297
|
+
case BaseExceptionGroup(exceptions=(BaseException() as only,)):
|
|
298
|
+
return failure(only)
|
|
299
|
+
case _:
|
|
300
|
+
return exc
|
|
301
|
+
|
|
302
|
+
try:
|
|
303
|
+
asyncio.run(self.communicate())
|
|
304
|
+
except BaseException as exc:
|
|
305
|
+
self._failure = failure(exc)
|
|
306
|
+
|
|
307
|
+
asyncio.run(self.join())
|
|
308
|
+
self._last_step_at = self._clock.now()
|
|
309
|
+
self._thread = threading.Thread(
|
|
310
|
+
target=communicate, name=f"gradientmail-{self._learner_id}", daemon=True
|
|
311
|
+
)
|
|
312
|
+
self._thread.start()
|
|
313
|
+
|
|
314
|
+
def step(self, samples: int) -> None:
|
|
315
|
+
"""Account for one training step of ``samples`` tokens; apply and publish as due.
|
|
316
|
+
|
|
317
|
+
Queued fragment versions are validated against the schema and applied here, while the
|
|
318
|
+
model is quiescent, and so is the snapshot of a fragment whose round is due. Only bytes
|
|
319
|
+
are copied; the delta, packing, hashing and I/O happen on the communication thread. When
|
|
320
|
+
the schedule bounds ``overlap_steps``, it then waits until no round published that many
|
|
321
|
+
steps ago is still outstanding; the wait does not count as step time.
|
|
322
|
+
"""
|
|
323
|
+
if self._failure is not None:
|
|
324
|
+
raise self._failure
|
|
325
|
+
if self._run is None:
|
|
326
|
+
raise RuntimeError("Learner.step() before start()")
|
|
327
|
+
run = self._run
|
|
328
|
+
now = self._clock.now()
|
|
329
|
+
state = advanced(self._state, samples, now - self._last_step_at)
|
|
330
|
+
self._last_step_at = now
|
|
331
|
+
state = self._drained(run, state)
|
|
332
|
+
round = due_round(
|
|
333
|
+
state,
|
|
334
|
+
run.schedule,
|
|
335
|
+
quorum=min(run.quorum, len(self._live)),
|
|
336
|
+
publishers=self._publishers,
|
|
337
|
+
)
|
|
338
|
+
if round is not None:
|
|
339
|
+
state = self._publish(run, state, round, final=False)
|
|
340
|
+
if (limit := run.schedule.overlap_steps) is not None:
|
|
341
|
+
state = self._settled(run, state, lambda s: not overdue(s, limit), patience=None)
|
|
342
|
+
self._last_step_at = self._clock.now()
|
|
343
|
+
self._state = state
|
|
344
|
+
|
|
345
|
+
def close(self) -> None:
|
|
346
|
+
"""Wait for the commits of this learner's rounds in flight and align with them, then stop.
|
|
347
|
+
|
|
348
|
+
The steps trained since a fragment's last round are dropped: the model is set back to
|
|
349
|
+
the newest version of every fragment it aligned with. With the algorithm's
|
|
350
|
+
``flush_on_close`` they are published instead, as final snapshots, round after round,
|
|
351
|
+
each awaited and aligned with, so the run's parameters end with them and the model holds
|
|
352
|
+
them. The wait gives up once nothing has landed for the lease timeout plus the takeover
|
|
353
|
+
timeout the last record predicts (at least thirty polls): time for crashed learners to
|
|
354
|
+
be presumed lost, then for a round without them to be presumed not to commit, as when
|
|
355
|
+
nobody is left to close one. Then the lease is released, the outbox is drained, bounded
|
|
356
|
+
by ``_CLOSE_TIMEOUT`` seconds, and the communication thread is joined. Closing again
|
|
357
|
+
does nothing.
|
|
358
|
+
"""
|
|
359
|
+
if self._thread is None:
|
|
360
|
+
return
|
|
361
|
+
try:
|
|
362
|
+
if self._run is not None and self._failure is None:
|
|
363
|
+
self._state = self._concluded(self._run, self._state)
|
|
364
|
+
if not self._algorithm.flush_on_close:
|
|
365
|
+
for anchor in self._anchors:
|
|
366
|
+
self._adapter.apply(anchor)
|
|
367
|
+
finally:
|
|
368
|
+
self._outbox.put(None)
|
|
369
|
+
self._thread.join(_CLOSE_TIMEOUT)
|
|
370
|
+
if self._thread.is_alive() and self._main is not None:
|
|
371
|
+
self._on_event(PublishCancelled(self._learner_id, _CLOSE_TIMEOUT))
|
|
372
|
+
self._main.get_loop().call_soon_threadsafe(self._main.cancel)
|
|
373
|
+
self._thread.join()
|
|
374
|
+
self._thread = None
|
|
375
|
+
|
|
376
|
+
async def join(self) -> None:
|
|
377
|
+
"""Create the run or adopt it, then load the newest committed version of every fragment.
|
|
378
|
+
|
|
379
|
+
Adoption requires the model's schema and the algorithm's configuration to equal the
|
|
380
|
+
manifest's (``SchemaMismatch`` naming the differing parameters, ``RunConfigMismatch``).
|
|
381
|
+
Counters start at zero; a restart takes this same path.
|
|
382
|
+
"""
|
|
383
|
+
schema = self._adapter.schema()
|
|
384
|
+
algorithm = self._algorithm.to_json()
|
|
385
|
+
manifest = await self._exchange.manifest()
|
|
386
|
+
if manifest is None:
|
|
387
|
+
manifest = await self.create(schema)
|
|
388
|
+
if manifest.algorithm != algorithm:
|
|
389
|
+
raise RunConfigMismatch(expected=algorithm, found=manifest.algorithm)
|
|
390
|
+
|
|
391
|
+
fragments = range(len(manifest.plan))
|
|
392
|
+
aligned = await align(self._adapter, self._exchange, manifest)
|
|
393
|
+
records = aligned.records
|
|
394
|
+
newest = _newest_per_fragment(records)
|
|
395
|
+
self._anchors = list(aligned.fragments)
|
|
396
|
+
self._newest = {
|
|
397
|
+
p: (newest[p].version if p in newest else 0, tensors)
|
|
398
|
+
for p, tensors in enumerate(aligned.fragments)
|
|
399
|
+
}
|
|
400
|
+
self._run = _Run(
|
|
401
|
+
schema=schema,
|
|
402
|
+
plan=manifest.plan,
|
|
403
|
+
schedule=self._algorithm.schedule(len(manifest.plan)),
|
|
404
|
+
quorum=self._algorithm.quorum(),
|
|
405
|
+
mixing=self._algorithm.mixing,
|
|
406
|
+
commit_dtype=self._algorithm.commit_dtype,
|
|
407
|
+
digest=schema.digest(),
|
|
408
|
+
dtypes={spec.name: spec.dtype for spec in schema.parameters},
|
|
409
|
+
shapes={spec.name: spec.shape for spec in schema.parameters},
|
|
410
|
+
)
|
|
411
|
+
self._state = LearnerState(
|
|
412
|
+
local_step=0,
|
|
413
|
+
steps=(0,) * len(manifest.plan),
|
|
414
|
+
tokens=(0,) * len(manifest.plan),
|
|
415
|
+
applied_version=tuple(newest[p].version if p in newest else 0 for p in fragments),
|
|
416
|
+
rounds_seen=records[-1].round if records else -1,
|
|
417
|
+
last_published=-1,
|
|
418
|
+
published=(),
|
|
419
|
+
step_seconds=0.0,
|
|
420
|
+
)
|
|
421
|
+
self._cursor = self._state.rounds_seen
|
|
422
|
+
if records:
|
|
423
|
+
self._estimates = records[-1].estimates
|
|
424
|
+
self._on_event(
|
|
425
|
+
Joined(
|
|
426
|
+
self._learner_id, manifest.created_by, len(manifest.plan), self._state.rounds_seen
|
|
427
|
+
)
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
async def create(self, schema: Schema[DType]) -> Manifest:
|
|
431
|
+
"""Create the run from this model, or wait for the node that is creating it.
|
|
432
|
+
|
|
433
|
+
The first claim on the manifest elects the creator, as the first claim on a round elects
|
|
434
|
+
its closer: one node uploads Θ⁰ and writes the manifest while the others poll for it,
|
|
435
|
+
instead of each uploading a Θ⁰ of its own only to lose the race. A waiter counts the
|
|
436
|
+
init payloads as they land; when none lands for as long as the largest fragment would
|
|
437
|
+
take at ``_MIN_UPLOAD_RATE``, and never less than ``_CREATION_FLOOR_POLLS`` polls, it
|
|
438
|
+
takes the next claim and creates the run itself. A creator that was slow rather than
|
|
439
|
+
dead adopts the manifest that won, as ``create_run`` does. A restarted creator finds
|
|
440
|
+
its own claim and resumes.
|
|
441
|
+
"""
|
|
442
|
+
plan = self._algorithm.plan(schema)
|
|
443
|
+
|
|
444
|
+
async def upload() -> Manifest:
|
|
445
|
+
draft = ManifestDraft(
|
|
446
|
+
schema=schema,
|
|
447
|
+
plan=plan,
|
|
448
|
+
algorithm=self._algorithm.to_json(),
|
|
449
|
+
created_by=self._learner_id,
|
|
450
|
+
)
|
|
451
|
+
return await self._exchange.create_run(
|
|
452
|
+
draft, [self._adapter.snapshot(names) for names in plan]
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
largest = max(sum(schema.nbytes(name) for name in names) for names in plan)
|
|
456
|
+
stall = max(_CREATION_FLOOR_POLLS * self._poll_interval, largest / _MIN_UPLOAD_RATE)
|
|
457
|
+
claimants = await self._exchange.creation_claims()
|
|
458
|
+
if self._learner_id in claimants or (
|
|
459
|
+
not claimants and await self._exchange.claim_creation(1, self._learner_id)
|
|
460
|
+
):
|
|
461
|
+
self._on_event(CreatingRun(self._learner_id, len(plan)))
|
|
462
|
+
return await upload()
|
|
463
|
+
claimants = await self._exchange.creation_claims()
|
|
464
|
+
attempts = len(claimants)
|
|
465
|
+
self._on_event(AwaitingCreator(self._learner_id, tuple(claimants)))
|
|
466
|
+
uploaded = await self._exchange.init_uploaded()
|
|
467
|
+
seen_at = self._clock.now()
|
|
468
|
+
while True:
|
|
469
|
+
await self._clock.sleep(self._poll_interval)
|
|
470
|
+
manifest = await self._exchange.manifest()
|
|
471
|
+
if manifest is not None:
|
|
472
|
+
return manifest
|
|
473
|
+
count = await self._exchange.init_uploaded()
|
|
474
|
+
if count != uploaded:
|
|
475
|
+
uploaded, seen_at = count, self._clock.now()
|
|
476
|
+
elif self._clock.now() - seen_at >= stall:
|
|
477
|
+
if await self._exchange.claim_creation(attempts + 1, self._learner_id):
|
|
478
|
+
self._on_event(TookOverCreation(self._learner_id, stall))
|
|
479
|
+
return await upload()
|
|
480
|
+
attempts = len(await self._exchange.creation_claims())
|
|
481
|
+
seen_at = self._clock.now()
|
|
482
|
+
|
|
483
|
+
async def communicate(self) -> None:
|
|
484
|
+
"""Receive, publish and renew the lease until ``close()``, then release the lease; what
|
|
485
|
+
the communication thread runs.
|
|
486
|
+
|
|
487
|
+
Neither loop dies of a failed operation: the failure is reported and the loop goes on,
|
|
488
|
+
the receive loop with its next poll (so the lease keeps being renewed), the publish loop
|
|
489
|
+
with the next snapshot (the failed round's steps stay counted and go out with the
|
|
490
|
+
fragment's next round). Only cancellation ends them, and the lease is released either
|
|
491
|
+
way.
|
|
492
|
+
"""
|
|
493
|
+
|
|
494
|
+
async def receive() -> None:
|
|
495
|
+
renew_at = self._clock.now()
|
|
496
|
+
while True:
|
|
497
|
+
try:
|
|
498
|
+
if self._clock.now() >= renew_at:
|
|
499
|
+
await self._exchange.beat(self._learner_id)
|
|
500
|
+
renew_at = self._clock.now() + self._lease_every
|
|
501
|
+
log.debug("%s: lease renewed", self._learner_id)
|
|
502
|
+
await self.poll()
|
|
503
|
+
except Exception as exc:
|
|
504
|
+
self._on_event(PollFailed(self._learner_id, exc))
|
|
505
|
+
await self._clock.sleep(self._poll_interval)
|
|
506
|
+
|
|
507
|
+
async def publish() -> None:
|
|
508
|
+
while (outgoing := await asyncio.to_thread(self._outbox.get)) is not None:
|
|
509
|
+
draft = outgoing.draft
|
|
510
|
+
try:
|
|
511
|
+
delta = await asyncio.to_thread(self._delta, run, outgoing)
|
|
512
|
+
started = time.perf_counter()
|
|
513
|
+
published = await self._exchange.publish_snapshot(draft, delta)
|
|
514
|
+
self._on_event(
|
|
515
|
+
SnapshotPublished(
|
|
516
|
+
self._learner_id,
|
|
517
|
+
draft.round,
|
|
518
|
+
run.schedule.fragment_of(draft.round),
|
|
519
|
+
draft.steps,
|
|
520
|
+
draft.tokens,
|
|
521
|
+
published.payload.size,
|
|
522
|
+
time.perf_counter() - started,
|
|
523
|
+
)
|
|
524
|
+
)
|
|
525
|
+
except Exception as exc:
|
|
526
|
+
self._on_event(PublishFailed(self._learner_id, draft.round, exc))
|
|
527
|
+
|
|
528
|
+
if self._run is None:
|
|
529
|
+
raise RuntimeError("Learner.communicate() before join()")
|
|
530
|
+
run = self._run
|
|
531
|
+
self._main = asyncio.current_task()
|
|
532
|
+
try:
|
|
533
|
+
async with asyncio.TaskGroup() as group:
|
|
534
|
+
receiving = group.create_task(receive())
|
|
535
|
+
await publish()
|
|
536
|
+
receiving.cancel()
|
|
537
|
+
finally:
|
|
538
|
+
# A cancelled publish leaves a worker thread blocked in outbox.get(), and asyncio.run
|
|
539
|
+
# waits for the executor's threads on shutdown; the sentinel releases it.
|
|
540
|
+
self._outbox.put(None)
|
|
541
|
+
try:
|
|
542
|
+
await self._exchange.release(self._learner_id)
|
|
543
|
+
self._on_event(LeaseReleased(self._learner_id))
|
|
544
|
+
except Exception as exc:
|
|
545
|
+
self._on_event(ReleaseFailed(self._learner_id, exc))
|
|
546
|
+
|
|
547
|
+
async def poll(self) -> None:
|
|
548
|
+
"""One receive iteration: queue the newest version of each fragment committed meanwhile,
|
|
549
|
+
then list the leases and who has published the round this learner publishes next.
|
|
550
|
+
|
|
551
|
+
The batch is queued only once every payload in it has been fetched. A payload already
|
|
552
|
+
swept is skipped; the next poll finds that fragment's newer version.
|
|
553
|
+
"""
|
|
554
|
+
if self._run is None:
|
|
555
|
+
raise RuntimeError("Learner.poll() before join()")
|
|
556
|
+
run = self._run
|
|
557
|
+
records = await self._exchange.rounds_after(self._cursor)
|
|
558
|
+
if records:
|
|
559
|
+
newest = tuple(_newest_per_fragment(records).values())
|
|
560
|
+
mine = {
|
|
561
|
+
record.fragment: participant
|
|
562
|
+
for record in records
|
|
563
|
+
for participant in record.participants
|
|
564
|
+
if participant.learner == self._learner_id
|
|
565
|
+
}
|
|
566
|
+
async with asyncio.TaskGroup() as group:
|
|
567
|
+
fetches = [group.create_task(self._fetched(run, record)) for record in newest]
|
|
568
|
+
updates = tuple(
|
|
569
|
+
_Update(
|
|
570
|
+
record.round,
|
|
571
|
+
record.fragment,
|
|
572
|
+
record.version,
|
|
573
|
+
tensors,
|
|
574
|
+
mine.get(record.fragment),
|
|
575
|
+
)
|
|
576
|
+
for record, fetch in zip(newest, fetches, strict=True)
|
|
577
|
+
if (tensors := fetch.result()) is not None
|
|
578
|
+
)
|
|
579
|
+
if updates:
|
|
580
|
+
self._inbox.put(updates)
|
|
581
|
+
self._cursor = records[-1].round
|
|
582
|
+
self._estimates = records[-1].estimates
|
|
583
|
+
for record in records:
|
|
584
|
+
self._on_event(RoundRecorded(self._learner_id, record))
|
|
585
|
+
now = self._clock.now()
|
|
586
|
+
self._liveness = self._liveness.observed(await self._exchange.leases(), now)
|
|
587
|
+
self._live = self._liveness.live(now)
|
|
588
|
+
state = self._state
|
|
589
|
+
round = max(state.last_published + 1, state.rounds_seen + 1)
|
|
590
|
+
others = await self._exchange.publishers(round)
|
|
591
|
+
self._publishers = Publishers(round, others - {self._learner_id})
|
|
592
|
+
|
|
593
|
+
def _publish(
|
|
594
|
+
self, run: "_Run", state: LearnerState, round: int, *, final: bool
|
|
595
|
+
) -> LearnerState:
|
|
596
|
+
"""Snapshot ``round``'s fragment and its anchor for the publish loop; the bytes are
|
|
597
|
+
copied here. ``final`` when closing."""
|
|
598
|
+
fragment = run.schedule.fragment_of(round)
|
|
599
|
+
draft = SnapshotDraft(
|
|
600
|
+
learner=self._learner_id,
|
|
601
|
+
round=round,
|
|
602
|
+
local_step=state.local_step,
|
|
603
|
+
steps=state.steps[fragment],
|
|
604
|
+
tokens=state.tokens[fragment],
|
|
605
|
+
base_version=state.applied_version[fragment],
|
|
606
|
+
rounds_seen=state.rounds_seen,
|
|
607
|
+
step_seconds=state.step_seconds,
|
|
608
|
+
schema_digest=run.digest,
|
|
609
|
+
final=final,
|
|
610
|
+
)
|
|
611
|
+
self._outbox.put(
|
|
612
|
+
_Outgoing(draft, self._anchors[fragment], self._adapter.snapshot(run.plan[fragment]))
|
|
613
|
+
)
|
|
614
|
+
log.debug(
|
|
615
|
+
"%s: round %d queued: fragment %d, %d steps at local step %d",
|
|
616
|
+
self._learner_id,
|
|
617
|
+
round,
|
|
618
|
+
fragment,
|
|
619
|
+
draft.steps,
|
|
620
|
+
state.local_step,
|
|
621
|
+
)
|
|
622
|
+
published = (*state.published, (round, state.local_step))[-run.schedule.in_flight :]
|
|
623
|
+
return replace(state, last_published=round, published=published)
|
|
624
|
+
|
|
625
|
+
async def _fetched(self, run: "_Run", record: RoundRecord) -> Mapping[str, TensorBytes] | None:
|
|
626
|
+
"""The committed bytes of ``record``'s fragment: rebuilt from the commit's update when
|
|
627
|
+
this learner holds the version before it and the result hashes to the record's theta,
|
|
628
|
+
otherwise fetched; None when swept."""
|
|
629
|
+
held = self._newest.get(record.fragment)
|
|
630
|
+
if record.update is not None and held is not None and held[0] == record.version - 1:
|
|
631
|
+
update = await self._exchange.fetch(record.update.payload)
|
|
632
|
+
if update is not None:
|
|
633
|
+
rebuilt = await asyncio.to_thread(
|
|
634
|
+
self._rebuilt, run, record.fragment, held[1], update
|
|
635
|
+
)
|
|
636
|
+
if theta_digest(rebuilt) == record.update.theta_digest:
|
|
637
|
+
self._newest[record.fragment] = (record.version, rebuilt)
|
|
638
|
+
return rebuilt
|
|
639
|
+
log.debug(
|
|
640
|
+
"%s: round %d: the update does not rebuild theta; fetching it",
|
|
641
|
+
self._learner_id,
|
|
642
|
+
record.round,
|
|
643
|
+
)
|
|
644
|
+
tensors = await self._exchange.fetch(record.theta)
|
|
645
|
+
if tensors is not None:
|
|
646
|
+
self._newest[record.fragment] = (record.version, tensors)
|
|
647
|
+
return tensors
|
|
648
|
+
|
|
649
|
+
def _rebuilt(
|
|
650
|
+
self,
|
|
651
|
+
run: "_Run",
|
|
652
|
+
fragment: int,
|
|
653
|
+
base: Mapping[str, TensorBytes],
|
|
654
|
+
update: Mapping[str, TensorBytes],
|
|
655
|
+
) -> Mapping[str, TensorBytes]:
|
|
656
|
+
"""``base`` plus the commit's update, rounded to the schema's dtypes."""
|
|
657
|
+
load = self._backend.load
|
|
658
|
+
return {
|
|
659
|
+
name: (
|
|
660
|
+
load(base[name])
|
|
661
|
+
+ decoded(self._backend, update, name, run.shapes[name], run.commit_dtype)
|
|
662
|
+
).to_bytes(run.dtypes[name])
|
|
663
|
+
for name in run.plan[fragment]
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
def _delta(self, run: "_Run", outgoing: "_Outgoing") -> Mapping[str, TensorBytes]:
|
|
667
|
+
"""``anchor - θ`` of every tensor in the snapshot, at the algorithm's delta precision."""
|
|
668
|
+
load = self._backend.load
|
|
669
|
+
return {
|
|
670
|
+
key: tensor
|
|
671
|
+
for name, local in outgoing.snapshot.items()
|
|
672
|
+
for key, tensor in encoded(
|
|
673
|
+
name,
|
|
674
|
+
load(outgoing.anchor[name]) - load(local),
|
|
675
|
+
run.dtypes[name],
|
|
676
|
+
self._algorithm.delta_dtype,
|
|
677
|
+
).items()
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
def _align(self, run: "_Run", state: LearnerState, update: "_Update") -> LearnerState:
|
|
681
|
+
"""Set the fragment to the committed version, blended with the local parameters when
|
|
682
|
+
the algorithm mixes; the committed bytes become the anchor either way."""
|
|
683
|
+
names = run.plan[update.fragment]
|
|
684
|
+
validate(run.schema, update.tensors, names)
|
|
685
|
+
self._adapter.apply(
|
|
686
|
+
self._blended(run, names, update.tensors) if run.mixing else update.tensors
|
|
687
|
+
)
|
|
688
|
+
self._anchors[update.fragment] = update.tensors
|
|
689
|
+
mine = update.participant
|
|
690
|
+
self._on_event(
|
|
691
|
+
FragmentAligned(
|
|
692
|
+
self._learner_id,
|
|
693
|
+
update.round,
|
|
694
|
+
update.fragment,
|
|
695
|
+
update.version,
|
|
696
|
+
state.local_step,
|
|
697
|
+
None if mine is None else state.local_step - mine.local_step,
|
|
698
|
+
)
|
|
699
|
+
)
|
|
700
|
+
return applied(
|
|
701
|
+
state,
|
|
702
|
+
update.round,
|
|
703
|
+
update.fragment,
|
|
704
|
+
update.version,
|
|
705
|
+
participant=mine,
|
|
706
|
+
mixing=run.mixing,
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
def _blended(
|
|
710
|
+
self, run: "_Run", names: Sequence[str], theta: Mapping[str, TensorBytes]
|
|
711
|
+
) -> Mapping[str, TensorBytes]:
|
|
712
|
+
"""``mixing·θ + (1 - mixing)·Θ`` over the fragment, rounded to the schema's dtypes."""
|
|
713
|
+
load = self._backend.load
|
|
714
|
+
local = self._adapter.snapshot(names)
|
|
715
|
+
return {
|
|
716
|
+
name: (load(local[name]) * run.mixing + load(theta[name]) * (1 - run.mixing)).to_bytes(
|
|
717
|
+
run.dtypes[name]
|
|
718
|
+
)
|
|
719
|
+
for name in names
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
def _drained(self, run: "_Run", state: LearnerState) -> LearnerState:
|
|
723
|
+
"""Align with every fragment version the receive loop has queued, without waiting."""
|
|
724
|
+
|
|
725
|
+
def queued() -> Iterator[_Update]:
|
|
726
|
+
while True:
|
|
727
|
+
try:
|
|
728
|
+
batch = self._inbox.get_nowait()
|
|
729
|
+
except queue.Empty:
|
|
730
|
+
return
|
|
731
|
+
yield from batch
|
|
732
|
+
|
|
733
|
+
for update in queued():
|
|
734
|
+
state = self._align(run, state, update)
|
|
735
|
+
return state
|
|
736
|
+
|
|
737
|
+
def _settled(
|
|
738
|
+
self,
|
|
739
|
+
run: "_Run",
|
|
740
|
+
state: LearnerState,
|
|
741
|
+
done: Callable[[LearnerState], bool],
|
|
742
|
+
*,
|
|
743
|
+
patience: float | None,
|
|
744
|
+
) -> LearnerState:
|
|
745
|
+
"""Align with fragment versions as they arrive until ``done(state)`` holds.
|
|
746
|
+
|
|
747
|
+
Blocks the calling thread. With ``patience``, returns once nothing has happened for that
|
|
748
|
+
long: no version aligned with and no new snapshot in the round this learner publishes
|
|
749
|
+
next. Raises what the communication thread died of.
|
|
750
|
+
"""
|
|
751
|
+
progress = (state.rounds_seen, self._publishers)
|
|
752
|
+
since = self._clock.now()
|
|
753
|
+
while not done(state):
|
|
754
|
+
try:
|
|
755
|
+
batch = self._inbox.get(timeout=self._poll_interval)
|
|
756
|
+
except queue.Empty:
|
|
757
|
+
batch = ()
|
|
758
|
+
if self._failure is not None:
|
|
759
|
+
raise self._failure
|
|
760
|
+
for update in batch:
|
|
761
|
+
state = self._align(run, state, update)
|
|
762
|
+
now = self._clock.now()
|
|
763
|
+
if (state.rounds_seen, self._publishers) != progress:
|
|
764
|
+
progress, since = (state.rounds_seen, self._publishers), now
|
|
765
|
+
elif patience is not None and now - since >= patience:
|
|
766
|
+
break
|
|
767
|
+
return state
|
|
768
|
+
|
|
769
|
+
def _concluded(self, run: "_Run", state: LearnerState) -> LearnerState:
|
|
770
|
+
"""Wait for the commits of the rounds in flight; with ``flush_on_close``, first publish
|
|
771
|
+
every fragment trained since its last overwrite as a final snapshot.
|
|
772
|
+
|
|
773
|
+
Flushed rounds go out as the schedule allows, ``in_flight`` at a time, each next one
|
|
774
|
+
once the commits make it publishable; done when nothing is publishable and nothing is
|
|
775
|
+
outstanding, or when the run stalls for the lease timeout plus the takeover timeout.
|
|
776
|
+
"""
|
|
777
|
+
|
|
778
|
+
def flushable(state: LearnerState) -> int | None:
|
|
779
|
+
return publishable(state, run.schedule) if self._algorithm.flush_on_close else None
|
|
780
|
+
|
|
781
|
+
def settled(state: LearnerState) -> bool:
|
|
782
|
+
return flushable(state) is not None or state.rounds_seen >= state.last_published
|
|
783
|
+
|
|
784
|
+
self._on_event(Closing(self._learner_id, self._algorithm.flush_on_close))
|
|
785
|
+
patience = self._lease_timeout + self._estimates.timeout(self._poll_interval)
|
|
786
|
+
state = self._drained(run, state)
|
|
787
|
+
while True:
|
|
788
|
+
while (round := flushable(state)) is not None:
|
|
789
|
+
state = self._publish(run, state, round, final=True)
|
|
790
|
+
if state.rounds_seen >= state.last_published:
|
|
791
|
+
self._on_event(Closed(self._learner_id, state.last_published))
|
|
792
|
+
return state
|
|
793
|
+
state = self._settled(run, state, settled, patience=patience)
|
|
794
|
+
if not settled(state):
|
|
795
|
+
self._on_event(
|
|
796
|
+
GaveUp(self._learner_id, state.last_published, patience, state.rounds_seen)
|
|
797
|
+
)
|
|
798
|
+
return state
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
@dataclass(frozen=True)
|
|
802
|
+
class Aligned:
|
|
803
|
+
"""What ``align`` loaded: the round records read, in round order, and the tensors the
|
|
804
|
+
adapter now holds, one mapping per fragment."""
|
|
805
|
+
|
|
806
|
+
records: Sequence[RoundRecord]
|
|
807
|
+
fragments: tuple[Mapping[str, TensorBytes], ...]
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
async def align(adapter: Adapter, exchange: Exchange, manifest: Manifest) -> Aligned:
|
|
811
|
+
"""Load the newest committed version of every fragment of ``manifest``'s run into ``adapter``.
|
|
812
|
+
|
|
813
|
+
A fragment no round has committed yet is loaded from the manifest's initial parameters.
|
|
814
|
+
``SchemaMismatch`` names the parameters on which the model and the run differ.
|
|
815
|
+
"""
|
|
816
|
+
schema = adapter.schema()
|
|
817
|
+
if manifest.schema != schema:
|
|
818
|
+
differing = set(manifest.schema.parameters) ^ set(schema.parameters)
|
|
819
|
+
raise SchemaMismatch(
|
|
820
|
+
sorted({spec.name for spec in differing}), "model does not match the run's schema"
|
|
821
|
+
)
|
|
822
|
+
fragments = range(len(manifest.plan))
|
|
823
|
+
records = await exchange.rounds_after(-1)
|
|
824
|
+
newest = _newest_per_fragment(records)
|
|
825
|
+
refs = [newest[p].theta if p in newest else manifest.init[p] for p in fragments]
|
|
826
|
+
loaded: list[Mapping[str, TensorBytes]] = []
|
|
827
|
+
for fragment, (ref, tensors) in enumerate(zip(refs, await _fetch(exchange, refs), strict=True)):
|
|
828
|
+
if tensors is None:
|
|
829
|
+
raise ProtocolError(f"{ref.key} was swept while loading; start again")
|
|
830
|
+
validate(schema, tensors, manifest.plan[fragment])
|
|
831
|
+
adapter.apply(tensors)
|
|
832
|
+
loaded.append(tensors)
|
|
833
|
+
return Aligned(records, tuple(loaded))
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
@dataclass(frozen=True)
|
|
837
|
+
class _Run:
|
|
838
|
+
"""The run as joined: fixed for the learner's lifetime."""
|
|
839
|
+
|
|
840
|
+
schema: Schema[DType]
|
|
841
|
+
plan: tuple[tuple[str, ...], ...]
|
|
842
|
+
schedule: RoundSchedule
|
|
843
|
+
quorum: int
|
|
844
|
+
mixing: float
|
|
845
|
+
commit_dtype: Precision
|
|
846
|
+
digest: str
|
|
847
|
+
dtypes: Mapping[str, DType]
|
|
848
|
+
shapes: Mapping[str, tuple[int, ...]]
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
@dataclass(frozen=True)
|
|
852
|
+
class _Update:
|
|
853
|
+
"""A fragment version fetched by the receive loop, waiting for ``step()`` to apply it;
|
|
854
|
+
``participant`` is this learner's snapshot in the fragment's rounds of the same listing,
|
|
855
|
+
when it took part in one."""
|
|
856
|
+
|
|
857
|
+
round: int
|
|
858
|
+
fragment: int
|
|
859
|
+
version: int
|
|
860
|
+
tensors: Mapping[str, TensorBytes]
|
|
861
|
+
participant: Participant | None
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
@dataclass(frozen=True)
|
|
865
|
+
class _Outgoing:
|
|
866
|
+
"""A snapshot taken by ``step()`` with the anchor it was trained from, waiting for the
|
|
867
|
+
publish loop to subtract them."""
|
|
868
|
+
|
|
869
|
+
draft: SnapshotDraft
|
|
870
|
+
anchor: Mapping[str, TensorBytes]
|
|
871
|
+
snapshot: Mapping[str, TensorBytes]
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def _newest_per_fragment(records: Sequence[RoundRecord]) -> Mapping[int, RoundRecord]:
|
|
875
|
+
# Records come in round order, so the last one seen per fragment is its newest version.
|
|
876
|
+
return {record.fragment: record for record in records}
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
async def _fetch(
|
|
880
|
+
exchange: Exchange, refs: Sequence[PayloadRef]
|
|
881
|
+
) -> Sequence[Mapping[str, TensorBytes] | None]:
|
|
882
|
+
async with asyncio.TaskGroup() as group:
|
|
883
|
+
fetches = [group.create_task(exchange.fetch(ref)) for ref in refs]
|
|
884
|
+
return [fetch.result() for fetch in fetches]
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
_EMA_DECAY = 0.1
|
|
888
|
+
_CLOSE_TIMEOUT = 60.0
|
|
889
|
+
_CREATION_FLOOR_POLLS = 30
|
|
890
|
+
# A live creator is assumed to upload at least this fast; slower, and a waiter takes over.
|
|
891
|
+
_MIN_UPLOAD_RATE = 2**20
|