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.
@@ -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