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/protocol.py
ADDED
|
@@ -0,0 +1,839 @@
|
|
|
1
|
+
"""The learner-syncer exchange: records, their keys, the JSON codec and the operations.
|
|
2
|
+
|
|
3
|
+
Keys live under the mailbox's prefix; fixed widths make byte order equal numeric order::
|
|
4
|
+
|
|
5
|
+
manifest.json Manifest, create-once
|
|
6
|
+
rounds/{r:010d}.json RoundRecord: commit point of r
|
|
7
|
+
snapshots/{r:010d}/{learner}.json Snapshot of learner for round r
|
|
8
|
+
claims/{r:010d}/{n:03d}.json nth claim to close round r
|
|
9
|
+
claims/manifest/{n:03d}.json nth claim to create the run
|
|
10
|
+
leases/{learner}/{n:010d}.json nth beat of learner's lease
|
|
11
|
+
blobs/init/{p:03d}/{sha256}{ext} Θ⁰ candidates (Manifest.init)
|
|
12
|
+
blobs/rounds/{r:010d}/{sha256}{ext} theta, outer and update payloads of r
|
|
13
|
+
blobs/snapshots/{r:010d}/{learner}/{sha256}{ext} snapshot payload
|
|
14
|
+
|
|
15
|
+
Records and payloads live under different prefixes, so ``list("rounds/", after=...)`` returns
|
|
16
|
+
exactly the new commits and ``list("snapshots/{r}/")`` exactly the candidates of round ``r``.
|
|
17
|
+
``{ext}`` is ``.safetensors`` or ``.safetensors.zst``: the codec the writer stored the payload
|
|
18
|
+
with (``compression`` module), which every reader decodes by the extension alone.
|
|
19
|
+
|
|
20
|
+
Write ordering
|
|
21
|
+
--------------
|
|
22
|
+
A payload is written before the record that points at it: init payloads then the manifest, a
|
|
23
|
+
snapshot payload then its record, theta, outer and the update then the round record. With a
|
|
24
|
+
store that
|
|
25
|
+
lists and reads its own writes, a record a party can see always has durable payloads, and a
|
|
26
|
+
crash between the two writes leaves an orphan payload and no record. Snapshot orphans are
|
|
27
|
+
swept with their round and round-payload orphans with their round's payloads; the init
|
|
28
|
+
payloads of a party that crashed before the manifest write are never removed.
|
|
29
|
+
|
|
30
|
+
Idempotency
|
|
31
|
+
-----------
|
|
32
|
+
Every object is written exactly once with ``put_if_absent``. A payload key contains the sha256
|
|
33
|
+
of its bytes, so writing the same tensors again is a no-op and a record can never describe
|
|
34
|
+
bytes other than the ones it was written with. Records are keyed by identity (the manifest,
|
|
35
|
+
the round, the round and learner), so a retried write that finds its key taken is a success:
|
|
36
|
+
``publish_snapshot`` returns, ``commit`` compares payload keys and returns, ``create_run``
|
|
37
|
+
adopts the manifest that won and deletes the init payloads it does not reference. Two learners
|
|
38
|
+
racing to initialize therefore end up on one Θ⁰, and a learner that restarts and publishes a
|
|
39
|
+
second snapshot for a round it had already published changes nothing: the first record stands
|
|
40
|
+
and the second payload is swept with the round.
|
|
41
|
+
|
|
42
|
+
Election
|
|
43
|
+
--------
|
|
44
|
+
Every node runs the syncer loop; round ``r`` is closed by the node that created the round's
|
|
45
|
+
first claim, a create-once object naming the claimant. The others wait for the round record
|
|
46
|
+
and, after a timeout measured on their own clock, create the next claim and take over. Claims
|
|
47
|
+
only avoid redundant work: the round record is the commit point and the tape. A node whose
|
|
48
|
+
record write finds the key taken reads the record back; equal payload keys mean its own
|
|
49
|
+
earlier attempt landed (``Committed``), different keys mean another node closed the round and
|
|
50
|
+
``commit`` returns that record as ``Superseded`` for the loser to adopt. Round records are
|
|
51
|
+
never deleted, so ``rounds/`` is the full history of the run and the only input recovery needs.
|
|
52
|
+
|
|
53
|
+
Creating the run is elected the same way. The node that creates ``claims/manifest/001`` uploads
|
|
54
|
+
Θ⁰ and writes the manifest; the others poll for ``manifest.json`` instead of each uploading a
|
|
55
|
+
Θ⁰ of its own only to lose the race, which with bytes bodies costs the whole upload before the
|
|
56
|
+
412 arrives. A waiter watches the count of init payloads as a sign of life and, when none lands
|
|
57
|
+
for long enough (the ``learner`` module decides how long), creates the next claim and creates
|
|
58
|
+
the run itself; a creator that was slow rather than dead adopts the manifest that won.
|
|
59
|
+
|
|
60
|
+
Leases
|
|
61
|
+
------
|
|
62
|
+
A learner holds a lease while it runs. ``beat`` lists ``leases/{learner}/``, creates the beat
|
|
63
|
+
after the newest and deletes the rest, so the listing shows one object per learner (two during
|
|
64
|
+
a beat) and a restarted learner continues its own count; ``release`` deletes them all when the
|
|
65
|
+
learner closes. ``leases`` is one LIST of the whole prefix: the newest beat per learner. A lease
|
|
66
|
+
holds no time; the ``lease`` module judges liveness on the observer's own clock from how long
|
|
67
|
+
the newest beat has stayed the same. A learner that crashes leaves its last beat behind.
|
|
68
|
+
|
|
69
|
+
Failure modes
|
|
70
|
+
-------------
|
|
71
|
+
A listing may show a payload before its record (there is no atomicity across keys); every
|
|
72
|
+
reader follows records and never lists ``blobs/``. A record listed but gone by the time it is
|
|
73
|
+
read was swept meanwhile and is skipped. A snapshot that lands after its round closed is not
|
|
74
|
+
in the round record and is swept with the round; its learner's local progress on that fragment
|
|
75
|
+
is overwritten by the committed version. ``fetch`` returns None for a payload the sweeper has
|
|
76
|
+
removed (the syncer records the snapshot as ``missing``; a learner skips that fragment and
|
|
77
|
+
aligns with the newer version its next poll lists) and raises ``PayloadCorrupt`` when the bytes
|
|
78
|
+
do not hash to the record (the syncer records ``corrupt``; a learner fails, since a committed
|
|
79
|
+
payload that does not match its record is a store fault). Two processes publishing under one
|
|
80
|
+
learner id write one snapshot record per round, and the other's contribution is lost without
|
|
81
|
+
an error; the default id carries the process id for this reason. A malformed record raises
|
|
82
|
+
``ProtocolError``, and so does a manifest or round record that has disappeared, since the
|
|
83
|
+
protocol never deletes either. ``MailboxUnavailable`` is retried with exponential backoff
|
|
84
|
+
(0.5 s doubling to 30 s) for as long as it lasts; every other error propagates.
|
|
85
|
+
|
|
86
|
+
A ``*Draft`` carries the fields a party knows before anything is written; the ``Exchange``
|
|
87
|
+
completes it with the payload refs it wrote and stores the full record.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
import asyncio
|
|
91
|
+
import hashlib
|
|
92
|
+
import json
|
|
93
|
+
import logging
|
|
94
|
+
import re
|
|
95
|
+
import time
|
|
96
|
+
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
|
97
|
+
from dataclasses import dataclass
|
|
98
|
+
from functools import partial
|
|
99
|
+
from typing import Literal, assert_never
|
|
100
|
+
|
|
101
|
+
from gradientmail.algorithms import Estimates, Participant
|
|
102
|
+
from gradientmail.clock import Clock
|
|
103
|
+
from gradientmail.compression import EXTENSION, Compression, compression_of
|
|
104
|
+
from gradientmail.compression import decode as decode_payload
|
|
105
|
+
from gradientmail.compression import encode as encode_payload
|
|
106
|
+
from gradientmail.mailbox import Mailbox, retrying
|
|
107
|
+
from gradientmail.serialization import DType, ParameterSpec, Schema, TensorBytes, pack
|
|
108
|
+
|
|
109
|
+
log = logging.getLogger(__name__)
|
|
110
|
+
|
|
111
|
+
type Rejection = Literal["stale", "zero_steps", "schema", "corrupt", "missing"]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True)
|
|
115
|
+
class PayloadRef:
|
|
116
|
+
key: str
|
|
117
|
+
sha256: str
|
|
118
|
+
size: int
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(frozen=True)
|
|
122
|
+
class ManifestDraft:
|
|
123
|
+
schema: Schema[DType]
|
|
124
|
+
plan: tuple[tuple[str, ...], ...]
|
|
125
|
+
algorithm: Mapping[str, object]
|
|
126
|
+
created_by: str
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True)
|
|
130
|
+
class Manifest(ManifestDraft):
|
|
131
|
+
"""The run as created: ``init`` holds one Θ⁰ payload per fragment of ``plan``."""
|
|
132
|
+
|
|
133
|
+
init: tuple[PayloadRef, ...]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@dataclass(frozen=True)
|
|
137
|
+
class SnapshotDraft:
|
|
138
|
+
"""``final`` marks a snapshot published by ``close()``: the learner trains no further."""
|
|
139
|
+
|
|
140
|
+
learner: str
|
|
141
|
+
round: int
|
|
142
|
+
local_step: int
|
|
143
|
+
steps: int
|
|
144
|
+
tokens: int
|
|
145
|
+
base_version: int
|
|
146
|
+
rounds_seen: int
|
|
147
|
+
step_seconds: float
|
|
148
|
+
schema_digest: str
|
|
149
|
+
final: bool
|
|
150
|
+
|
|
151
|
+
def __post_init__(self) -> None:
|
|
152
|
+
if not _LEARNER_ID.fullmatch(self.learner):
|
|
153
|
+
raise ProtocolError(f"learner id {self.learner!r} must match [a-z0-9-]{{1,64}}")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass(frozen=True)
|
|
157
|
+
class Snapshot(SnapshotDraft):
|
|
158
|
+
payload: PayloadRef
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass(frozen=True)
|
|
162
|
+
class RoundRecordDraft:
|
|
163
|
+
"""``cosine`` is the mean cosine similarity between the participants' deltas over the
|
|
164
|
+
fragment, None with fewer than two; ``quorum_seconds`` and ``grace_seconds`` are this
|
|
165
|
+
round's realized waits; ``estimates`` are the timing EMAs entering the next round, so
|
|
166
|
+
whoever closes it continues them."""
|
|
167
|
+
|
|
168
|
+
round: int
|
|
169
|
+
step: int
|
|
170
|
+
fragment: int
|
|
171
|
+
version: int
|
|
172
|
+
participants: tuple[Participant, ...]
|
|
173
|
+
rejected: tuple[tuple[str, Rejection], ...]
|
|
174
|
+
cosine: float | None
|
|
175
|
+
quorum_seconds: float
|
|
176
|
+
grace_seconds: float
|
|
177
|
+
estimates: Estimates
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@dataclass(frozen=True)
|
|
181
|
+
class CommitUpdate:
|
|
182
|
+
"""``Θ_new - Θ_old`` at the algorithm's commit precision: a learner holding the old version
|
|
183
|
+
applies it to its copy instead of fetching theta and checks the result against
|
|
184
|
+
``theta_digest``, the sha256 of theta packed as safetensors, whatever codec stored it."""
|
|
185
|
+
|
|
186
|
+
payload: PayloadRef
|
|
187
|
+
theta_digest: str
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@dataclass(frozen=True)
|
|
191
|
+
class RoundRecord(RoundRecordDraft):
|
|
192
|
+
theta: PayloadRef
|
|
193
|
+
outer: PayloadRef
|
|
194
|
+
update: CommitUpdate | None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
type Record = Manifest | Snapshot | RoundRecord
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@dataclass(frozen=True)
|
|
201
|
+
class Committed:
|
|
202
|
+
"""``commit`` wrote ``record``, now or in an earlier attempt that landed."""
|
|
203
|
+
|
|
204
|
+
record: RoundRecord
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@dataclass(frozen=True)
|
|
208
|
+
class Superseded:
|
|
209
|
+
"""Another node had committed the round first; ``record`` is theirs."""
|
|
210
|
+
|
|
211
|
+
record: RoundRecord
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
type CommitOutcome = Committed | Superseded
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class ProtocolError(ValueError):
|
|
218
|
+
"""A record that does not follow the protocol: malformed JSON, a bad field, a missing key."""
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class RunConfigMismatch(ValueError):
|
|
222
|
+
"""The run's manifest was created with a different algorithm configuration."""
|
|
223
|
+
|
|
224
|
+
expected: Mapping[str, object]
|
|
225
|
+
found: Mapping[str, object]
|
|
226
|
+
|
|
227
|
+
def __init__(self, expected: Mapping[str, object], found: Mapping[str, object]) -> None:
|
|
228
|
+
self.expected = expected
|
|
229
|
+
self.found = found
|
|
230
|
+
super().__init__(
|
|
231
|
+
f"run was created with a different algorithm configuration: {dict(found)!r}, "
|
|
232
|
+
f"this party has {dict(expected)!r}"
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class PayloadCorrupt(Exception):
|
|
237
|
+
"""The bytes under ``ref.key`` do not hash to ``ref.sha256``."""
|
|
238
|
+
|
|
239
|
+
ref: PayloadRef
|
|
240
|
+
actual: str
|
|
241
|
+
|
|
242
|
+
def __init__(self, ref: PayloadRef, actual: str) -> None:
|
|
243
|
+
self.ref = ref
|
|
244
|
+
self.actual = actual
|
|
245
|
+
super().__init__(f"{ref.key} hashes to {actual}, record says {ref.sha256}")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
MANIFEST_KEY = "manifest.json"
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def round_key(round: int) -> str:
|
|
252
|
+
return f"rounds/{round:010d}.json"
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def snapshots_prefix(round: int) -> str:
|
|
256
|
+
return f"snapshots/{round:010d}/"
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def snapshot_key(round: int, learner: str) -> str:
|
|
260
|
+
return f"{snapshots_prefix(round)}{learner}.json"
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def claim_key(round: int, attempt: int) -> str:
|
|
264
|
+
return f"claims/{round:010d}/{attempt:03d}.json"
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
CREATION_CLAIMS = "claims/manifest/"
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def creation_claim_key(attempt: int) -> str:
|
|
271
|
+
return f"{CREATION_CLAIMS}{attempt:03d}.json"
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
LEASES = "leases/"
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def lease_prefix(learner: str) -> str:
|
|
278
|
+
return f"{LEASES}{learner}/"
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def lease_key(learner: str, beat: int) -> str:
|
|
282
|
+
return f"{lease_prefix(learner)}{beat:010d}.json"
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def init_blob_key(fragment: int, sha256: str) -> str:
|
|
286
|
+
"""The payload key without its codec extension, as every ``*_blob_key`` is."""
|
|
287
|
+
return f"blobs/init/{fragment:03d}/{sha256}"
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def round_blob_key(round: int, sha256: str) -> str:
|
|
291
|
+
return f"blobs/rounds/{round:010d}/{sha256}"
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def snapshot_blob_key(round: int, learner: str, sha256: str) -> str:
|
|
295
|
+
return f"blobs/snapshots/{round:010d}/{learner}/{sha256}"
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def encode(record: Record) -> bytes:
|
|
299
|
+
"""Canonical JSON: sorted keys, no whitespace."""
|
|
300
|
+
|
|
301
|
+
def ref(payload: PayloadRef) -> Mapping[str, object]:
|
|
302
|
+
return {"key": payload.key, "sha256": payload.sha256, "size": payload.size}
|
|
303
|
+
|
|
304
|
+
def body() -> Mapping[str, object]:
|
|
305
|
+
match record:
|
|
306
|
+
case Manifest():
|
|
307
|
+
return {
|
|
308
|
+
"schema": [
|
|
309
|
+
{"name": p.name, "dtype": p.dtype, "shape": p.shape}
|
|
310
|
+
for p in record.schema.parameters
|
|
311
|
+
],
|
|
312
|
+
"plan": record.plan,
|
|
313
|
+
"algorithm": dict(record.algorithm),
|
|
314
|
+
"init": [ref(payload) for payload in record.init],
|
|
315
|
+
"created_by": record.created_by,
|
|
316
|
+
}
|
|
317
|
+
case Snapshot():
|
|
318
|
+
return {
|
|
319
|
+
"learner": record.learner,
|
|
320
|
+
"round": record.round,
|
|
321
|
+
"local_step": record.local_step,
|
|
322
|
+
"steps": record.steps,
|
|
323
|
+
"tokens": record.tokens,
|
|
324
|
+
"base_version": record.base_version,
|
|
325
|
+
"rounds_seen": record.rounds_seen,
|
|
326
|
+
"step_seconds": record.step_seconds,
|
|
327
|
+
"schema_digest": record.schema_digest,
|
|
328
|
+
"final": record.final,
|
|
329
|
+
"payload": ref(record.payload),
|
|
330
|
+
}
|
|
331
|
+
case RoundRecord():
|
|
332
|
+
return {
|
|
333
|
+
"round": record.round,
|
|
334
|
+
"step": record.step,
|
|
335
|
+
"fragment": record.fragment,
|
|
336
|
+
"version": record.version,
|
|
337
|
+
"participants": [
|
|
338
|
+
{
|
|
339
|
+
"learner": p.learner,
|
|
340
|
+
"local_step": p.local_step,
|
|
341
|
+
"steps": p.steps,
|
|
342
|
+
"tokens": p.tokens,
|
|
343
|
+
"weight": p.weight,
|
|
344
|
+
}
|
|
345
|
+
for p in record.participants
|
|
346
|
+
],
|
|
347
|
+
"rejected": record.rejected,
|
|
348
|
+
"cosine": record.cosine,
|
|
349
|
+
"theta": ref(record.theta),
|
|
350
|
+
"outer": ref(record.outer),
|
|
351
|
+
"update": None
|
|
352
|
+
if record.update is None
|
|
353
|
+
else {
|
|
354
|
+
"payload": ref(record.update.payload),
|
|
355
|
+
"theta_digest": record.update.theta_digest,
|
|
356
|
+
},
|
|
357
|
+
"quorum_seconds": record.quorum_seconds,
|
|
358
|
+
"grace_seconds": record.grace_seconds,
|
|
359
|
+
"estimates": {
|
|
360
|
+
"step": record.estimates.step,
|
|
361
|
+
"quorum": record.estimates.quorum,
|
|
362
|
+
"sync": record.estimates.sync,
|
|
363
|
+
},
|
|
364
|
+
}
|
|
365
|
+
case _:
|
|
366
|
+
assert_never(record)
|
|
367
|
+
|
|
368
|
+
return json.dumps(body(), sort_keys=True, separators=(",", ":")).encode()
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def decode_manifest(data: bytes) -> Manifest:
|
|
372
|
+
def parameter(fields: _Fields) -> ParameterSpec[DType]:
|
|
373
|
+
return ParameterSpec(
|
|
374
|
+
fields.string("name"),
|
|
375
|
+
dtype(fields.values.get("dtype")),
|
|
376
|
+
tuple(_integer(size, "shape") for size in fields.items("shape")),
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
def dtype(value: _Json) -> DType:
|
|
380
|
+
match value:
|
|
381
|
+
case "F32" | "F16" | "BF16" as code:
|
|
382
|
+
return code
|
|
383
|
+
case other:
|
|
384
|
+
raise ProtocolError(f"dtype: expected F32, F16 or BF16, got {other!r}")
|
|
385
|
+
|
|
386
|
+
def schema(fields: _Fields) -> Schema[DType]:
|
|
387
|
+
parameters = tuple(parameter(entry) for entry in fields.each("schema"))
|
|
388
|
+
try:
|
|
389
|
+
return Schema(parameters)
|
|
390
|
+
except ValueError as exc:
|
|
391
|
+
raise ProtocolError(f"schema: {exc}") from exc
|
|
392
|
+
|
|
393
|
+
fields = _parse(data)
|
|
394
|
+
return Manifest(
|
|
395
|
+
schema=schema(fields),
|
|
396
|
+
plan=tuple(
|
|
397
|
+
tuple(_string(name, "plan") for name in _items(fragment, "plan"))
|
|
398
|
+
for fragment in fields.items("plan")
|
|
399
|
+
),
|
|
400
|
+
algorithm=fields.nested("algorithm").values,
|
|
401
|
+
created_by=fields.string("created_by"),
|
|
402
|
+
init=tuple(_ref(entry) for entry in fields.each("init")),
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def decode_snapshot(data: bytes) -> Snapshot:
|
|
407
|
+
fields = _parse(data)
|
|
408
|
+
return Snapshot(
|
|
409
|
+
learner=fields.string("learner"),
|
|
410
|
+
round=fields.integer("round"),
|
|
411
|
+
local_step=fields.integer("local_step"),
|
|
412
|
+
steps=fields.integer("steps"),
|
|
413
|
+
tokens=fields.integer("tokens"),
|
|
414
|
+
base_version=fields.integer("base_version"),
|
|
415
|
+
rounds_seen=fields.integer("rounds_seen"),
|
|
416
|
+
step_seconds=fields.number("step_seconds"),
|
|
417
|
+
schema_digest=fields.string("schema_digest"),
|
|
418
|
+
final=fields.boolean("final"),
|
|
419
|
+
payload=_ref(fields.nested("payload")),
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def decode_round_record(data: bytes) -> RoundRecord:
|
|
424
|
+
def participant(fields: _Fields) -> Participant:
|
|
425
|
+
return Participant(
|
|
426
|
+
learner=fields.string("learner"),
|
|
427
|
+
local_step=fields.integer("local_step"),
|
|
428
|
+
steps=fields.integer("steps"),
|
|
429
|
+
tokens=fields.integer("tokens"),
|
|
430
|
+
weight=fields.number("weight"),
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
def rejection(value: _Json) -> tuple[str, Rejection]:
|
|
434
|
+
match value:
|
|
435
|
+
case [
|
|
436
|
+
str() as learner,
|
|
437
|
+
"stale" | "zero_steps" | "schema" | "corrupt" | "missing" as why,
|
|
438
|
+
]:
|
|
439
|
+
return learner, why
|
|
440
|
+
case other:
|
|
441
|
+
raise ProtocolError(f"rejected: expected [learner, reason], got {other!r}")
|
|
442
|
+
|
|
443
|
+
def estimates(fields: _Fields) -> Estimates:
|
|
444
|
+
return Estimates(
|
|
445
|
+
step=fields.optional_number("step"),
|
|
446
|
+
quorum=fields.optional_number("quorum"),
|
|
447
|
+
sync=fields.optional_number("sync"),
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
def update(fields: _Fields) -> CommitUpdate | None:
|
|
451
|
+
if fields.values.get("update") is None:
|
|
452
|
+
return None
|
|
453
|
+
nested = fields.nested("update")
|
|
454
|
+
return CommitUpdate(_ref(nested.nested("payload")), nested.string("theta_digest"))
|
|
455
|
+
|
|
456
|
+
fields = _parse(data)
|
|
457
|
+
return RoundRecord(
|
|
458
|
+
round=fields.integer("round"),
|
|
459
|
+
step=fields.integer("step"),
|
|
460
|
+
fragment=fields.integer("fragment"),
|
|
461
|
+
version=fields.integer("version"),
|
|
462
|
+
participants=tuple(participant(entry) for entry in fields.each("participants")),
|
|
463
|
+
rejected=tuple(rejection(entry) for entry in fields.items("rejected")),
|
|
464
|
+
cosine=fields.optional_number("cosine"),
|
|
465
|
+
theta=_ref(fields.nested("theta")),
|
|
466
|
+
outer=_ref(fields.nested("outer")),
|
|
467
|
+
update=update(fields),
|
|
468
|
+
quorum_seconds=fields.number("quorum_seconds"),
|
|
469
|
+
grace_seconds=fields.number("grace_seconds"),
|
|
470
|
+
estimates=estimates(fields.nested("estimates")),
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
class Exchange:
|
|
475
|
+
"""The protocol operations over a ``Mailbox``.
|
|
476
|
+
|
|
477
|
+
Every operation runs through ``retrying``: a ``MailboxUnavailable`` is retried with
|
|
478
|
+
backoff on ``clock``; anything else propagates. ``compression`` is the codec this exchange
|
|
479
|
+
writes payloads with; it reads payloads in any.
|
|
480
|
+
"""
|
|
481
|
+
|
|
482
|
+
def __init__(self, mailbox: Mailbox, *, clock: Clock, compression: Compression = "off") -> None:
|
|
483
|
+
self._mailbox = mailbox
|
|
484
|
+
self._clock = clock
|
|
485
|
+
self._compression = compression
|
|
486
|
+
|
|
487
|
+
async def manifest(self) -> Manifest | None:
|
|
488
|
+
data = await self._retry(partial(self._mailbox.get, MANIFEST_KEY))
|
|
489
|
+
return None if data is None else decode_manifest(data)
|
|
490
|
+
|
|
491
|
+
async def create_run(
|
|
492
|
+
self, draft: ManifestDraft, init: Sequence[Mapping[str, TensorBytes]]
|
|
493
|
+
) -> Manifest:
|
|
494
|
+
"""Create the run from ``draft`` with ``init[p]`` as Θ⁰ of fragment ``p``.
|
|
495
|
+
|
|
496
|
+
Init payloads are written first, the manifest last. When another party created the run
|
|
497
|
+
first, this caller's payloads that the winner does not reference are deleted and the
|
|
498
|
+
winner's manifest is returned, so racing learners always start from one Θ⁰.
|
|
499
|
+
"""
|
|
500
|
+
if len(init) != len(draft.plan):
|
|
501
|
+
raise ValueError(f"{len(draft.plan)} fragments planned but {len(init)} init payloads")
|
|
502
|
+
refs = tuple(
|
|
503
|
+
[
|
|
504
|
+
await self._publish(partial(init_blob_key, fragment), tensors)
|
|
505
|
+
for fragment, tensors in enumerate(init)
|
|
506
|
+
]
|
|
507
|
+
)
|
|
508
|
+
manifest = Manifest(
|
|
509
|
+
schema=draft.schema,
|
|
510
|
+
plan=draft.plan,
|
|
511
|
+
algorithm=draft.algorithm,
|
|
512
|
+
created_by=draft.created_by,
|
|
513
|
+
init=refs,
|
|
514
|
+
)
|
|
515
|
+
created = await self._retry(
|
|
516
|
+
partial(self._mailbox.put_if_absent, MANIFEST_KEY, encode(manifest))
|
|
517
|
+
)
|
|
518
|
+
if created:
|
|
519
|
+
return manifest
|
|
520
|
+
winner = decode_manifest(await self._require(MANIFEST_KEY))
|
|
521
|
+
kept = {ref.key for ref in winner.init}
|
|
522
|
+
orphans = tuple(ref.key for ref in refs if ref.key not in kept)
|
|
523
|
+
await self._retry(partial(self._mailbox.delete, orphans))
|
|
524
|
+
return winner
|
|
525
|
+
|
|
526
|
+
async def publish_snapshot(
|
|
527
|
+
self, draft: SnapshotDraft, tensors: Mapping[str, TensorBytes]
|
|
528
|
+
) -> Snapshot:
|
|
529
|
+
"""Publish ``tensors`` as ``draft``'s snapshot; idempotent for one (round, learner).
|
|
530
|
+
|
|
531
|
+
Returns the snapshot as published, its payload reference included.
|
|
532
|
+
"""
|
|
533
|
+
payload = await self._publish(
|
|
534
|
+
partial(snapshot_blob_key, draft.round, draft.learner), tensors
|
|
535
|
+
)
|
|
536
|
+
snapshot = Snapshot(
|
|
537
|
+
learner=draft.learner,
|
|
538
|
+
round=draft.round,
|
|
539
|
+
local_step=draft.local_step,
|
|
540
|
+
steps=draft.steps,
|
|
541
|
+
tokens=draft.tokens,
|
|
542
|
+
base_version=draft.base_version,
|
|
543
|
+
rounds_seen=draft.rounds_seen,
|
|
544
|
+
step_seconds=draft.step_seconds,
|
|
545
|
+
schema_digest=draft.schema_digest,
|
|
546
|
+
final=draft.final,
|
|
547
|
+
payload=payload,
|
|
548
|
+
)
|
|
549
|
+
# False means this (round, learner) already has a record: an earlier attempt landed.
|
|
550
|
+
await self._retry(
|
|
551
|
+
partial(
|
|
552
|
+
self._mailbox.put_if_absent,
|
|
553
|
+
snapshot_key(draft.round, draft.learner),
|
|
554
|
+
encode(snapshot),
|
|
555
|
+
)
|
|
556
|
+
)
|
|
557
|
+
return snapshot
|
|
558
|
+
|
|
559
|
+
async def snapshots(self, round: int) -> Sequence[Snapshot]:
|
|
560
|
+
return await self._records(snapshots_prefix(round), decode_snapshot)
|
|
561
|
+
|
|
562
|
+
async def publishers(self, round: int) -> frozenset[str]:
|
|
563
|
+
"""The learners with a snapshot record in ``round``, from the listing alone."""
|
|
564
|
+
prefix = snapshots_prefix(round)
|
|
565
|
+
keys = await self._retry(partial(self._mailbox.list, prefix))
|
|
566
|
+
return frozenset(key[len(prefix) : -len(".json")] for key in keys)
|
|
567
|
+
|
|
568
|
+
async def claim(self, round: int, attempt: int, learner: str) -> bool:
|
|
569
|
+
"""Create the ``attempt``-th claim on round ``round``; True when it is ``learner``'s."""
|
|
570
|
+
return await self._claim(claim_key(round, attempt), learner)
|
|
571
|
+
|
|
572
|
+
async def claims(self, round: int) -> Sequence[str]:
|
|
573
|
+
"""The claimants of round ``round`` in attempt order."""
|
|
574
|
+
return await self._claimants(f"claims/{round:010d}/")
|
|
575
|
+
|
|
576
|
+
async def claim_creation(self, attempt: int, learner: str) -> bool:
|
|
577
|
+
"""Create the ``attempt``-th claim on creating the run; True when it is ``learner``'s."""
|
|
578
|
+
return await self._claim(creation_claim_key(attempt), learner)
|
|
579
|
+
|
|
580
|
+
async def creation_claims(self) -> Sequence[str]:
|
|
581
|
+
"""The claimants on creating the run, in attempt order."""
|
|
582
|
+
return await self._claimants(CREATION_CLAIMS)
|
|
583
|
+
|
|
584
|
+
async def init_uploaded(self) -> int:
|
|
585
|
+
"""How many Θ⁰ payloads exist so far: a creator's sign of life, counted and never read."""
|
|
586
|
+
return len(await self._retry(partial(self._mailbox.list, "blobs/init/")))
|
|
587
|
+
|
|
588
|
+
async def beat(self, learner: str) -> None:
|
|
589
|
+
"""Renew ``learner``'s lease: create the beat after its newest and delete the older ones."""
|
|
590
|
+
older = await self._retry(partial(self._mailbox.list, lease_prefix(learner)))
|
|
591
|
+
beat = max((_lease_of(key)[1] for key in older), default=-1) + 1
|
|
592
|
+
# False means this beat landed in an earlier attempt whose response was lost.
|
|
593
|
+
await self._retry(partial(self._mailbox.put_if_absent, lease_key(learner, beat), b"{}"))
|
|
594
|
+
await self._retry(partial(self._mailbox.delete, older))
|
|
595
|
+
|
|
596
|
+
async def release(self, learner: str) -> None:
|
|
597
|
+
"""Delete ``learner``'s lease: it is leaving the run."""
|
|
598
|
+
keys = await self._retry(partial(self._mailbox.list, lease_prefix(learner)))
|
|
599
|
+
await self._retry(partial(self._mailbox.delete, keys))
|
|
600
|
+
|
|
601
|
+
async def leases(self) -> Mapping[str, int]:
|
|
602
|
+
"""The newest beat of every learner holding a lease, from one listing."""
|
|
603
|
+
keys = await self._retry(partial(self._mailbox.list, LEASES))
|
|
604
|
+
# Keys come in byte order and beats have a fixed width, so a learner's last key is its
|
|
605
|
+
# newest beat.
|
|
606
|
+
return dict(_lease_of(key) for key in keys)
|
|
607
|
+
|
|
608
|
+
async def rounds_after(self, round: int) -> Sequence[RoundRecord]:
|
|
609
|
+
"""Round records newer than ``round`` in round order; ``-1`` means all of them."""
|
|
610
|
+
after = round_key(round) if round >= 0 else ""
|
|
611
|
+
return await self._records("rounds/", decode_round_record, after=after)
|
|
612
|
+
|
|
613
|
+
async def fetch(self, ref: PayloadRef) -> Mapping[str, TensorBytes] | None:
|
|
614
|
+
"""The tensors behind ``ref``; None once swept, ``PayloadCorrupt`` on a sha mismatch."""
|
|
615
|
+
compression = compression_of(ref.key)
|
|
616
|
+
if compression is None:
|
|
617
|
+
raise ProtocolError(f"{ref.key} names no payload codec")
|
|
618
|
+
started = time.perf_counter()
|
|
619
|
+
blob = await self._retry(partial(self._mailbox.get, ref.key))
|
|
620
|
+
if blob is None:
|
|
621
|
+
return None
|
|
622
|
+
log.debug("got %s: %d bytes in %.1f s", ref.key, len(blob), time.perf_counter() - started)
|
|
623
|
+
actual = _sha256(blob)
|
|
624
|
+
if actual != ref.sha256:
|
|
625
|
+
raise PayloadCorrupt(ref, actual)
|
|
626
|
+
return decode_payload(blob, compression)
|
|
627
|
+
|
|
628
|
+
async def commit(
|
|
629
|
+
self,
|
|
630
|
+
draft: RoundRecordDraft,
|
|
631
|
+
theta: Mapping[str, TensorBytes],
|
|
632
|
+
outer: Mapping[str, TensorBytes],
|
|
633
|
+
update: Mapping[str, TensorBytes] | None = None,
|
|
634
|
+
) -> CommitOutcome:
|
|
635
|
+
"""Commit round ``draft.round``: theta, outer and the update, if any, then the record.
|
|
636
|
+
|
|
637
|
+
A record already present with the same payload keys is this node's own earlier attempt
|
|
638
|
+
and counts as ``Committed``; different keys mean another node closed the round, and its
|
|
639
|
+
record comes back as ``Superseded``.
|
|
640
|
+
"""
|
|
641
|
+
blob_key = partial(round_blob_key, draft.round)
|
|
642
|
+
theta_ref = await self._publish(blob_key, theta)
|
|
643
|
+
outer_ref = await self._publish(blob_key, outer)
|
|
644
|
+
update_ref = (
|
|
645
|
+
None
|
|
646
|
+
if update is None
|
|
647
|
+
else CommitUpdate(await self._publish(blob_key, update), theta_digest(theta))
|
|
648
|
+
)
|
|
649
|
+
record = RoundRecord(
|
|
650
|
+
round=draft.round,
|
|
651
|
+
step=draft.step,
|
|
652
|
+
fragment=draft.fragment,
|
|
653
|
+
version=draft.version,
|
|
654
|
+
participants=draft.participants,
|
|
655
|
+
rejected=draft.rejected,
|
|
656
|
+
cosine=draft.cosine,
|
|
657
|
+
quorum_seconds=draft.quorum_seconds,
|
|
658
|
+
grace_seconds=draft.grace_seconds,
|
|
659
|
+
estimates=draft.estimates,
|
|
660
|
+
theta=theta_ref,
|
|
661
|
+
outer=outer_ref,
|
|
662
|
+
update=update_ref,
|
|
663
|
+
)
|
|
664
|
+
key = round_key(draft.round)
|
|
665
|
+
created = await self._retry(partial(self._mailbox.put_if_absent, key, encode(record)))
|
|
666
|
+
if created:
|
|
667
|
+
return Committed(record)
|
|
668
|
+
existing = decode_round_record(await self._require(key))
|
|
669
|
+
if _payload_keys(existing) == _payload_keys(record):
|
|
670
|
+
return Committed(existing)
|
|
671
|
+
return Superseded(existing)
|
|
672
|
+
|
|
673
|
+
async def sweep_snapshots(self, round: int) -> None:
|
|
674
|
+
"""Delete round ``round``'s snapshot records, payloads and claims, orphans included."""
|
|
675
|
+
records = await self._retry(partial(self._mailbox.list, f"snapshots/{round:010d}/"))
|
|
676
|
+
payloads = await self._retry(partial(self._mailbox.list, f"blobs/snapshots/{round:010d}/"))
|
|
677
|
+
claims = await self._retry(partial(self._mailbox.list, f"claims/{round:010d}/"))
|
|
678
|
+
await self._retry(partial(self._mailbox.delete, (*records, *payloads, *claims)))
|
|
679
|
+
|
|
680
|
+
async def sweep_blobs(self, round: int) -> None:
|
|
681
|
+
"""Delete round ``round``'s theta and outer payloads; its record is never deleted."""
|
|
682
|
+
if round < 0:
|
|
683
|
+
return
|
|
684
|
+
payloads = await self._retry(partial(self._mailbox.list, f"blobs/rounds/{round:010d}/"))
|
|
685
|
+
await self._retry(partial(self._mailbox.delete, payloads))
|
|
686
|
+
|
|
687
|
+
async def _publish(
|
|
688
|
+
self, key_of: Callable[[str], str], tensors: Mapping[str, TensorBytes]
|
|
689
|
+
) -> PayloadRef:
|
|
690
|
+
blob = encode_payload(tensors, self._compression)
|
|
691
|
+
sha256 = _sha256(blob)
|
|
692
|
+
ref = PayloadRef(key_of(sha256) + EXTENSION[self._compression], sha256, len(blob))
|
|
693
|
+
started = time.perf_counter()
|
|
694
|
+
# False means identical bytes are already there: the key contains their hash.
|
|
695
|
+
await self._retry(partial(self._mailbox.put_if_absent, ref.key, blob))
|
|
696
|
+
log.debug("put %s: %d bytes in %.1f s", ref.key, len(blob), time.perf_counter() - started)
|
|
697
|
+
return ref
|
|
698
|
+
|
|
699
|
+
async def _claim(self, key: str, learner: str) -> bool:
|
|
700
|
+
body = json.dumps({"learner": learner}, sort_keys=True, separators=(",", ":")).encode()
|
|
701
|
+
if await self._retry(partial(self._mailbox.put_if_absent, key, body)):
|
|
702
|
+
return True
|
|
703
|
+
return _parse(await self._require(key)).string("learner") == learner
|
|
704
|
+
|
|
705
|
+
async def _claimants(self, prefix: str) -> Sequence[str]:
|
|
706
|
+
return await self._records(prefix, lambda data: _parse(data).string("learner"))
|
|
707
|
+
|
|
708
|
+
async def _records[T](
|
|
709
|
+
self, prefix: str, decode: Callable[[bytes], T], *, after: str = ""
|
|
710
|
+
) -> Sequence[T]:
|
|
711
|
+
keys = await self._retry(partial(self._mailbox.list, prefix, after=after))
|
|
712
|
+
async with asyncio.TaskGroup() as group:
|
|
713
|
+
reads = [
|
|
714
|
+
group.create_task(self._retry(partial(self._mailbox.get, key))) for key in keys
|
|
715
|
+
]
|
|
716
|
+
# A record listed but gone by the time it is read was swept meanwhile; skip it.
|
|
717
|
+
return tuple(decode(data) for read in reads if (data := read.result()) is not None)
|
|
718
|
+
|
|
719
|
+
async def _require(self, key: str) -> bytes:
|
|
720
|
+
data = await self._retry(partial(self._mailbox.get, key))
|
|
721
|
+
if data is None:
|
|
722
|
+
raise ProtocolError(f"{key} exists but could not be read back")
|
|
723
|
+
return data
|
|
724
|
+
|
|
725
|
+
async def _retry[T](self, op: Callable[[], Awaitable[T]]) -> T:
|
|
726
|
+
return await retrying(op, clock=self._clock)
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
type _Json = bool | int | float | str | list[_Json] | dict[str, _Json] | None
|
|
730
|
+
|
|
731
|
+
_LEARNER_ID = re.compile(r"[a-z0-9-]{1,64}")
|
|
732
|
+
_LEASE_KEY = re.compile(rf"{LEASES}({_LEARNER_ID.pattern})/(\d{{10}})\.json")
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _lease_of(key: str) -> tuple[str, int]:
|
|
736
|
+
match = _LEASE_KEY.fullmatch(key)
|
|
737
|
+
if match is None:
|
|
738
|
+
raise ProtocolError(f"{key} is not a lease key")
|
|
739
|
+
return match[1], int(match[2])
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
@dataclass(frozen=True)
|
|
743
|
+
class _Fields:
|
|
744
|
+
"""Typed access to one decoded JSON object; every mismatch is a ``ProtocolError``."""
|
|
745
|
+
|
|
746
|
+
values: Mapping[str, _Json]
|
|
747
|
+
|
|
748
|
+
def string(self, key: str) -> str:
|
|
749
|
+
return _string(self.values.get(key), key)
|
|
750
|
+
|
|
751
|
+
def integer(self, key: str) -> int:
|
|
752
|
+
return _integer(self.values.get(key), key)
|
|
753
|
+
|
|
754
|
+
def boolean(self, key: str) -> bool:
|
|
755
|
+
match self.values.get(key):
|
|
756
|
+
case bool() as value:
|
|
757
|
+
return value
|
|
758
|
+
case other:
|
|
759
|
+
raise ProtocolError(f"{key}: expected a boolean, got {other!r}")
|
|
760
|
+
|
|
761
|
+
def number(self, key: str) -> float:
|
|
762
|
+
match self.values.get(key):
|
|
763
|
+
case int() | float() as value:
|
|
764
|
+
return float(value)
|
|
765
|
+
case other:
|
|
766
|
+
raise ProtocolError(f"{key}: expected a number, got {other!r}")
|
|
767
|
+
|
|
768
|
+
def optional_number(self, key: str) -> float | None:
|
|
769
|
+
return None if self.values.get(key) is None else self.number(key)
|
|
770
|
+
|
|
771
|
+
def items(self, key: str) -> Sequence[_Json]:
|
|
772
|
+
return _items(self.values.get(key), key)
|
|
773
|
+
|
|
774
|
+
def nested(self, key: str) -> "_Fields":
|
|
775
|
+
return _object(self.values.get(key), key)
|
|
776
|
+
|
|
777
|
+
def each(self, key: str) -> Sequence["_Fields"]:
|
|
778
|
+
return tuple(_object(item, key) for item in self.items(key))
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def _parse(data: bytes) -> _Fields:
|
|
782
|
+
try:
|
|
783
|
+
parsed: _Json = json.loads(data)
|
|
784
|
+
except ValueError as exc:
|
|
785
|
+
raise ProtocolError("record is not JSON") from exc
|
|
786
|
+
return _object(parsed, "record")
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def _object(value: _Json, where: str) -> _Fields:
|
|
790
|
+
match value:
|
|
791
|
+
case dict() as values:
|
|
792
|
+
return _Fields(values)
|
|
793
|
+
case other:
|
|
794
|
+
raise ProtocolError(f"{where}: expected an object, got {other!r}")
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def _items(value: _Json, where: str) -> Sequence[_Json]:
|
|
798
|
+
match value:
|
|
799
|
+
case list() as items:
|
|
800
|
+
return items
|
|
801
|
+
case other:
|
|
802
|
+
raise ProtocolError(f"{where}: expected an array, got {other!r}")
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def _string(value: _Json, where: str) -> str:
|
|
806
|
+
match value:
|
|
807
|
+
case str() as text:
|
|
808
|
+
return text
|
|
809
|
+
case other:
|
|
810
|
+
raise ProtocolError(f"{where}: expected a string, got {other!r}")
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def _integer(value: _Json, where: str) -> int:
|
|
814
|
+
match value:
|
|
815
|
+
case int() as number:
|
|
816
|
+
return number
|
|
817
|
+
case other:
|
|
818
|
+
raise ProtocolError(f"{where}: expected an integer, got {other!r}")
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def _ref(fields: _Fields) -> PayloadRef:
|
|
822
|
+
return PayloadRef(fields.string("key"), fields.string("sha256"), fields.integer("size"))
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def _payload_keys(record: RoundRecord) -> tuple[str, str, str | None]:
|
|
826
|
+
return (
|
|
827
|
+
record.theta.key,
|
|
828
|
+
record.outer.key,
|
|
829
|
+
None if record.update is None else record.update.payload.key,
|
|
830
|
+
)
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def theta_digest(theta: Mapping[str, TensorBytes]) -> str:
|
|
834
|
+
"""The sha256 of ``theta`` packed as safetensors: the same whatever codec stores it."""
|
|
835
|
+
return _sha256(pack(theta))
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def _sha256(blob: bytes) -> str:
|
|
839
|
+
return hashlib.sha256(blob).hexdigest()
|