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,521 @@
1
+ """DiLoCo: its configuration, the pure pieces of its round logic and the outer step.
2
+
3
+ The outer step (``DiLoCoAggregator.close_round``) is written once, over the ``Value``
4
+ arithmetic of whatever ``Backend`` the syncer runs on, so this module imports no framework.
5
+ """
6
+
7
+ import math
8
+ import re
9
+ from collections.abc import Mapping, Sequence
10
+ from dataclasses import dataclass
11
+ from functools import reduce
12
+ from statistics import fmean
13
+ from typing import Self, assert_never
14
+
15
+ from gradientmail.adapters import Backend, Value
16
+ from gradientmail.algorithms import (
17
+ Aggregator,
18
+ Algorithm,
19
+ Contribution,
20
+ Estimates,
21
+ FragmentState,
22
+ Participant,
23
+ RoundResult,
24
+ RoundSchedule,
25
+ decoded,
26
+ encoded,
27
+ )
28
+ from gradientmail.serialization import DType, ParameterSpec, Quantized, Schema, TensorBytes
29
+
30
+ __all__ = [
31
+ "ByBytes",
32
+ "DiLoCo",
33
+ "DiLoCoAggregator",
34
+ "Merge",
35
+ "Partition",
36
+ "Rda",
37
+ "Strided",
38
+ "WeightedMean",
39
+ "grace_window",
40
+ "mean_cosine",
41
+ "nesterov",
42
+ "plan",
43
+ "rda",
44
+ "weight",
45
+ "weighted_mean",
46
+ ]
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class WeightedMean:
51
+ """Merge every tensor's deltas by their weighted mean."""
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class Rda:
56
+ """Weighted RDA for every tensor except those whose lowercased name contains
57
+ ``direct_pattern``, which use the weighted mean (the paper's treatment of embeddings)."""
58
+
59
+ direct_pattern: str = "embed"
60
+
61
+
62
+ type Merge = WeightedMean | Rda
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class ByBytes:
67
+ """Fragments of balanced byte size: tensors largest first, each into the lightest so far."""
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class Strided:
72
+ """Streaming DiLoCo's strided pattern: the transformer blocks, in index order, go round the
73
+ fragments, so fragment ``p`` holds every ``P``-th block from the ``p``-th. A block is the
74
+ tensors sharing the integer the first group of ``block_pattern`` captures from their names;
75
+ tensors it does not match (embeddings, the final norm) are bin-packed by bytes onto the
76
+ fragments the blocks made."""
77
+
78
+ block_pattern: str = r"\.(\d+)\."
79
+
80
+ def __post_init__(self) -> None:
81
+ if re.compile(self.block_pattern).groups < 1:
82
+ raise ValueError(
83
+ f"block_pattern needs a group for the block index: {self.block_pattern!r}"
84
+ )
85
+
86
+
87
+ type Partition = ByBytes | Strided
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class DiLoCo(Algorithm):
92
+ """DiLoCo configuration: ``sync_every`` is H, ``fragments`` is P, ``in_flight`` is τ.
93
+
94
+ The defaults are Decoupled DiLoCo's. Standard DiLoCo is ``fragments=1, in_flight=1,
95
+ overlap_steps=0, merge=WeightedMean()`` with ``quorum_size`` equal to the number of
96
+ learners; Local SGD is ``outer_lr=1, outer_momentum=0, merge=WeightedMean()``.
97
+
98
+ ``overlap_steps`` bounds how far training runs ahead of a published round: once a round a
99
+ learner published that many steps ago is still not aligned with, its ``step()`` waits for
100
+ the round's commit. ``0`` is standard DiLoCo's barrier. ``None``, the default, never waits:
101
+ training goes on and the commit overwrites the steps taken meanwhile.
102
+
103
+ ``mixing`` is Streaming DiLoCo's alpha: aligning a fragment sets it to ``mixing·θ + (1 -
104
+ mixing)·Θ``, the local parameters blended with the commit, instead of the commit alone. The
105
+ published Θ stays the anchor, so the next delta carries that share of the local progress the
106
+ commit did not include, and the fragment's counters keep the same share of their steps and
107
+ tokens. In the paper's Figure 8, at ten overlapped steps, 0.5 loses a third of what 0 loses.
108
+
109
+ ``global_batch_size`` fixes the batch the live learners share: each trains on its share,
110
+ so a learner joining shrinks the others' batches instead of growing the global batch.
111
+ ``None`` leaves every learner's batch its own.
112
+
113
+ ``delta_dtype`` is the format learners publish their deltas (``anchor - θ``) in. ``None``
114
+ keeps each parameter's own dtype, the same bytes as publishing θ; ``"BF16"`` halves an fp32
115
+ model's uploads, rounding each element to 8 significant bits; ``"F8_E4M3"`` quarters them,
116
+ at 4 significant bits with one power-of-two scale per tensor; ``"F4"`` takes an eighth, at 2
117
+ significant bits with a scale per 32 elements (the MX layout), which the paper found to cost
118
+ nothing in loss. Scales travel as ``<name>.scale`` entries and dequantize exactly.
119
+
120
+ ``commit_dtype`` makes every commit publish, beside theta, ``Θ_new - Θ_old`` in that
121
+ format: a learner holding the old version downloads the update instead of theta and
122
+ rebuilds theta from it, checking the result against the record. The published theta is
123
+ ``Θ_old`` plus the dequantized update, so the rounding stays in the fp32 master and the
124
+ next round corrects it. ``None`` publishes theta alone.
125
+
126
+ ``flush_on_close`` makes a closing learner publish the steps trained since each fragment's
127
+ last round as final snapshots, and a round of final snapshots alone commits the closing
128
+ learners' merged parameters without the outer step. Off, as in the DiLoCo papers, closing
129
+ waits only for the rounds in flight and those steps are dropped: in a run of many learners
130
+ the parameters of the few that close last are worse than the merged ones they replace.
131
+
132
+ ``partition`` is how the model's tensors are split into fragments: ``ByBytes`` balances
133
+ their sizes, ``Strided`` deals the transformer blocks round the fragments as Streaming
134
+ DiLoCo does.
135
+ """
136
+
137
+ sync_every: int
138
+ fragments: int
139
+ in_flight: int = 2
140
+ overlap_steps: int | None = None
141
+ mixing: float = 0.0
142
+ quorum_size: int = 1
143
+ outer_lr: float = 0.7
144
+ outer_momentum: float = 0.9
145
+ merge: Merge = Rda()
146
+ partition: Partition = ByBytes()
147
+ global_batch_size: int | None = None
148
+ delta_dtype: DType | Quantized | None = None
149
+ commit_dtype: DType | Quantized | None = None
150
+ flush_on_close: bool = False
151
+
152
+ def __post_init__(self) -> None:
153
+ if not 1 <= self.in_flight <= self.fragments <= self.sync_every:
154
+ raise ValueError(
155
+ "need 1 <= in_flight <= fragments <= sync_every, got "
156
+ f"in_flight={self.in_flight}, fragments={self.fragments}, "
157
+ f"sync_every={self.sync_every}"
158
+ )
159
+ if self.overlap_steps is not None and self.overlap_steps < 0:
160
+ raise ValueError(f"overlap_steps must be at least 0, got {self.overlap_steps}")
161
+ if not 0.0 <= self.mixing < 1.0:
162
+ raise ValueError(f"mixing must be in [0, 1), got {self.mixing}")
163
+ if self.quorum_size < 1:
164
+ raise ValueError(f"quorum_size must be at least 1, got {self.quorum_size}")
165
+ if self.global_batch_size is not None and self.global_batch_size < 1:
166
+ raise ValueError(f"global_batch_size must be at least 1, got {self.global_batch_size}")
167
+
168
+ def schedule(self, fragments: int) -> RoundSchedule:
169
+ if fragments < self.in_flight:
170
+ raise ValueError(
171
+ f"in_flight={self.in_flight} exceeds the {fragments} fragment(s) the model allows"
172
+ )
173
+ return RoundSchedule(self.sync_every, fragments, self.in_flight, self.overlap_steps)
174
+
175
+ def plan(self, schema: Schema[DType]) -> tuple[tuple[str, ...], ...]:
176
+ return plan(schema, self.fragments, self.partition)
177
+
178
+ def aggregator(
179
+ self, schema: Schema[DType], names: Sequence[str], backend: Backend
180
+ ) -> Aggregator:
181
+ return DiLoCoAggregator(config=self, schema=schema, names=names, backend=backend)
182
+
183
+ def quorum(self) -> int:
184
+ return self.quorum_size
185
+
186
+ def global_batch(self) -> int | None:
187
+ return self.global_batch_size
188
+
189
+ def to_json(self) -> Mapping[str, object]:
190
+ def merge() -> Mapping[str, object]:
191
+ match self.merge:
192
+ case WeightedMean():
193
+ return {"kind": "weighted_mean"}
194
+ case Rda(direct_pattern=pattern):
195
+ return {"kind": "rda", "direct_pattern": pattern}
196
+ case _:
197
+ assert_never(self.merge)
198
+
199
+ def partition() -> Mapping[str, object]:
200
+ match self.partition:
201
+ case ByBytes():
202
+ return {"kind": "by_bytes"}
203
+ case Strided(block_pattern=pattern):
204
+ return {"kind": "strided", "block_pattern": pattern}
205
+ case _:
206
+ assert_never(self.partition)
207
+
208
+ return {
209
+ "sync_every": self.sync_every,
210
+ "fragments": self.fragments,
211
+ "in_flight": self.in_flight,
212
+ "overlap_steps": self.overlap_steps,
213
+ "mixing": self.mixing,
214
+ "quorum_size": self.quorum_size,
215
+ "outer_lr": self.outer_lr,
216
+ "outer_momentum": self.outer_momentum,
217
+ "merge": merge(),
218
+ "partition": partition(),
219
+ "global_batch_size": self.global_batch_size,
220
+ "delta_dtype": self.delta_dtype,
221
+ "commit_dtype": self.commit_dtype,
222
+ "flush_on_close": self.flush_on_close,
223
+ }
224
+
225
+ @classmethod
226
+ def from_json(cls, data: Mapping[str, object]) -> Self:
227
+ """Inverse of ``to_json``; raises ``ValueError`` on a record of another shape."""
228
+
229
+ def integer(key: str) -> int:
230
+ match data.get(key):
231
+ case int() as value:
232
+ return value
233
+ case other:
234
+ raise ValueError(f"{key!r} must be an integer, got {other!r}")
235
+
236
+ def number(key: str) -> float:
237
+ match data.get(key):
238
+ case int() | float() as value:
239
+ return float(value)
240
+ case other:
241
+ raise ValueError(f"{key!r} must be a number, got {other!r}")
242
+
243
+ def boolean(key: str) -> bool:
244
+ match data.get(key):
245
+ case bool() as value:
246
+ return value
247
+ case other:
248
+ raise ValueError(f"{key!r} must be a boolean, got {other!r}")
249
+
250
+ def optional_integer(key: str) -> int | None:
251
+ match data.get(key):
252
+ case None:
253
+ return None
254
+ case int() as value:
255
+ return value
256
+ case other:
257
+ raise ValueError(f"{key!r} must be an integer or null, got {other!r}")
258
+
259
+ def optional_dtype(key: str) -> DType | Quantized | None:
260
+ match data.get(key):
261
+ case None:
262
+ return None
263
+ case "F32" | "F16" | "BF16" | "F8_E4M3" | "F4" as value:
264
+ return value
265
+ case other:
266
+ raise ValueError(
267
+ f"{key!r} must be F32, F16, BF16, F8_E4M3, F4 or null, got {other!r}"
268
+ )
269
+
270
+ def merge() -> Merge:
271
+ found = data.get("merge")
272
+ if not isinstance(found, Mapping):
273
+ raise ValueError(f"'merge' must be an object, got {found!r}")
274
+ match found.get("kind"), found.get("direct_pattern"):
275
+ case "weighted_mean", None:
276
+ return WeightedMean()
277
+ case "rda", str() as pattern:
278
+ return Rda(pattern)
279
+ case _:
280
+ raise ValueError(f"unrecognized merge {dict(found)!r}")
281
+
282
+ def partition() -> Partition:
283
+ found = data.get("partition")
284
+ if not isinstance(found, Mapping):
285
+ raise ValueError(f"'partition' must be an object, got {found!r}")
286
+ match found.get("kind"), found.get("block_pattern"):
287
+ case "by_bytes", None:
288
+ return ByBytes()
289
+ case "strided", str() as pattern:
290
+ return Strided(pattern)
291
+ case _:
292
+ raise ValueError(f"unrecognized partition {dict(found)!r}")
293
+
294
+ return cls(
295
+ sync_every=integer("sync_every"),
296
+ fragments=integer("fragments"),
297
+ in_flight=integer("in_flight"),
298
+ overlap_steps=optional_integer("overlap_steps"),
299
+ mixing=number("mixing"),
300
+ quorum_size=integer("quorum_size"),
301
+ outer_lr=number("outer_lr"),
302
+ outer_momentum=number("outer_momentum"),
303
+ merge=merge(),
304
+ partition=partition(),
305
+ global_batch_size=optional_integer("global_batch_size"),
306
+ delta_dtype=optional_dtype("delta_dtype"),
307
+ commit_dtype=optional_dtype("commit_dtype"),
308
+ flush_on_close=boolean("flush_on_close"),
309
+ )
310
+
311
+
312
+ @dataclass(frozen=True)
313
+ class DiLoCoAggregator(Aggregator):
314
+ """Closes one round of the fragment holding ``names`` with DiLoCo's outer step.
315
+
316
+ The contributions are the deltas the learners computed against the published ``theta``,
317
+ which is the anchor as is, so a learner whose bytes did not change contributes an exactly
318
+ zero delta. The fp32 master lives in ``outer["master.<name>"]`` when the published tensor
319
+ is not the master, that is when the schema dtype is not F32 or commits carry an update,
320
+ and the fp32 momentum in ``outer["momentum.<name>"]``. With a ``commit_dtype`` the
321
+ published theta is the anchor plus the update as it dequantizes, so what the update lost
322
+ stays in the master. Pure for one backend: equal inputs give equal bytes.
323
+
324
+ A round whose contributions are all final (``flush_on_close``) moves the master by the
325
+ merged delta alone and
326
+ keeps the momentum: the commit holds the closing learners' parameters. Nesterov's step
327
+ would also move it by ``η·β²·m``, about as far as a full round moves it however few steps
328
+ the deltas hold, and no training follows to correct that.
329
+ """
330
+
331
+ config: DiLoCo
332
+ schema: Schema[DType]
333
+ names: Sequence[str]
334
+ backend: Backend
335
+
336
+ def close_round(
337
+ self, state: FragmentState, contributions: Sequence[Contribution]
338
+ ) -> RoundResult:
339
+ def merged(name: str, deltas: Sequence[Value]) -> Value:
340
+ match self.config.merge:
341
+ case WeightedMean():
342
+ return weighted_mean(deltas, weights)
343
+ case Rda(direct_pattern=pattern):
344
+ if pattern in name.lower():
345
+ return weighted_mean(deltas, weights)
346
+ return rda(deltas, weights)
347
+ case _:
348
+ assert_never(self.config.merge)
349
+
350
+ def stepped(name: str) -> tuple[Value, Value, Value]:
351
+ """The anchor, the new master and the new momentum of ``name``."""
352
+ deltas = [
353
+ decoded(self.backend, c.tensors, name, shapes[name], self.config.delta_dtype)
354
+ for c in contributions
355
+ ]
356
+ for i, j in pairs:
357
+ gram[i][j] += deltas[i].dot(deltas[j])
358
+ anchor = load(state.theta[name])
359
+ delta = merged(name, deltas)
360
+ stored = state.outer.get(f"master.{name}")
361
+ master = anchor if stored is None else load(stored)
362
+ momentum = load(state.outer[f"momentum.{name}"])
363
+ if final:
364
+ return anchor, master - delta, momentum
365
+ return anchor, *nesterov(
366
+ master, momentum, delta, lr=self.config.outer_lr, beta=self.config.outer_momentum
367
+ )
368
+
369
+ def published(name: str, anchor: Value, master: Value) -> TensorBytes:
370
+ if self.config.commit_dtype is None:
371
+ return master.to_bytes(dtypes[name])
372
+ entries = encoded(name, master - anchor, dtypes[name], self.config.commit_dtype)
373
+ update.update(entries)
374
+ moved = decoded(self.backend, entries, name, shapes[name], self.config.commit_dtype)
375
+ return (anchor + moved).to_bytes(dtypes[name])
376
+
377
+ load = self.backend.load
378
+ final = all(c.final for c in contributions)
379
+ weights = [weight(c.tokens, c.steps) for c in contributions]
380
+ dtypes: Mapping[str, DType] = {spec.name: spec.dtype for spec in self.schema.parameters}
381
+ shapes = {spec.name: spec.shape for spec in self.schema.parameters}
382
+ count = len(contributions)
383
+ pairs = [(i, j) for i in range(count) for j in range(i, count)]
384
+ gram = [[0.0] * count for _ in range(count)]
385
+ update: dict[str, TensorBytes] = {}
386
+ stepped_by_name = {name: stepped(name) for name in self.names}
387
+ theta = {
388
+ name: published(name, anchor, master)
389
+ for name, (anchor, master, _) in stepped_by_name.items()
390
+ }
391
+ momenta = {
392
+ f"momentum.{name}": momentum.to_bytes("F32")
393
+ for name, (_, _, momentum) in stepped_by_name.items()
394
+ }
395
+ masters = {
396
+ f"master.{name}": master.to_bytes("F32")
397
+ for name, (_, master, _) in stepped_by_name.items()
398
+ if dtypes[name] != "F32" or self.config.commit_dtype is not None
399
+ }
400
+ participants = tuple(
401
+ Participant(c.learner, c.local_step, c.steps, c.tokens, w)
402
+ for c, w in zip(contributions, weights, strict=True)
403
+ )
404
+ return RoundResult(
405
+ FragmentState(state.version + 1, theta, {**momenta, **masters}),
406
+ participants,
407
+ mean_cosine(gram),
408
+ update if self.config.commit_dtype is not None else None,
409
+ )
410
+
411
+
412
+ def plan(
413
+ schema: Schema[DType], fragments: int, partition: Partition
414
+ ) -> tuple[tuple[str, ...], ...]:
415
+ """Split the schema's tensors into at most ``fragments`` fragments.
416
+
417
+ With ``ByBytes`` every tensor is bin-packed: taken largest first (ties by name), each goes
418
+ to the lightest fragment so far. With ``Strided`` the blocks are dealt round the fragments
419
+ first and only the tensors outside any block are bin-packed. The count is at most
420
+ ``min(fragments, len(parameters))``, fragments left empty are dropped, and the result is a
421
+ function of the schema alone, so every party computes the same plan.
422
+ """
423
+ count = min(fragments, len(schema.parameters))
424
+ names: list[list[str]] = [[] for _ in range(count)]
425
+ loads = [0] * count
426
+
427
+ def place(spec: ParameterSpec[DType], fragment: int) -> None:
428
+ names[fragment].append(spec.name)
429
+ loads[fragment] += schema.nbytes(spec.name)
430
+
431
+ loose: list[ParameterSpec[DType]] = []
432
+ match partition:
433
+ case ByBytes():
434
+ loose.extend(schema.parameters)
435
+ case Strided(block_pattern=pattern):
436
+ blocks: dict[int, list[ParameterSpec[DType]]] = {}
437
+ for spec in schema.parameters:
438
+ found = re.search(pattern, spec.name)
439
+ if found is None:
440
+ loose.append(spec)
441
+ else:
442
+ blocks.setdefault(int(found[1]), []).append(spec)
443
+ for rank, index in enumerate(sorted(blocks)):
444
+ for spec in blocks[index]:
445
+ place(spec, rank % count)
446
+ case _:
447
+ assert_never(partition)
448
+ for spec in sorted(loose, key=lambda spec: (-schema.nbytes(spec.name), spec.name)):
449
+ place(spec, min(range(count), key=loads.__getitem__))
450
+ return tuple(tuple(sorted(fragment)) for fragment in names if fragment)
451
+
452
+
453
+ def weight(tokens: int, steps: int) -> float:
454
+ """The paper's contribution weight ``tokens² / steps``, squared in exact integers first."""
455
+ return tokens * tokens / steps
456
+
457
+
458
+ def weighted_mean[V: Value](values: Sequence[V], weights: Sequence[float]) -> V:
459
+ """``Σ wᵢ·vᵢ / Σ wᵢ``, with the weights normalized in Python floats first."""
460
+ total = sum(weights)
461
+ scaled = (value * (w / total) for value, w in zip(values, weights, strict=True))
462
+ return reduce(lambda acc, value: acc + value, scaled)
463
+
464
+
465
+ def rda[V: Value](deltas: Sequence[V], weights: Sequence[float]) -> V:
466
+ """Weighted RDA: the weighted mean of the norms along the weighted mean direction.
467
+
468
+ ``(Σ wᵢ‖vᵢ‖ / Σ wᵢ) · φ(Σ wᵢ φ(vᵢ) / Σ wᵢ)`` with ``φ(x) = x / max(‖x‖, 1e-12)``, so the
469
+ result is zero when the directions cancel. Norms are taken per tensor.
470
+ """
471
+ norms = [delta.norm() for delta in deltas]
472
+ directions = [
473
+ delta * (1 / max(norm, _NORM_FLOOR)) for delta, norm in zip(deltas, norms, strict=True)
474
+ ]
475
+ mean_norm = sum(w * norm for w, norm in zip(weights, norms, strict=True)) / sum(weights)
476
+ mean_direction = weighted_mean(directions, weights)
477
+ return mean_direction * (mean_norm / max(mean_direction.norm(), _NORM_FLOOR))
478
+
479
+
480
+ def mean_cosine(gram: Sequence[Sequence[float]]) -> float | None:
481
+ """The mean cosine similarity between the vectors of a Gram matrix (its upper triangle
482
+ filled); None with fewer than two vectors, and a pair with a zero vector counts as 0."""
483
+
484
+ def cosine(i: int, j: int) -> float:
485
+ norms = math.sqrt(gram[i][i] * gram[j][j])
486
+ return gram[i][j] / norms if norms > 0.0 else 0.0
487
+
488
+ pairs = [(i, j) for i in range(len(gram)) for j in range(i + 1, len(gram))]
489
+ return fmean(cosine(i, j) for i, j in pairs) if pairs else None
490
+
491
+
492
+ def nesterov[V: Value](master: V, momentum: V, delta: V, *, lr: float, beta: float) -> tuple[V, V]:
493
+ """One Nesterov SGD step on the outer gradient ``delta``: returns ``(master, momentum)``.
494
+
495
+ ``m ← β·m + Δ`` and ``Θ ← Θ - η·(Δ + β·m)``.
496
+ """
497
+ updated = momentum * beta + delta
498
+ return master - (updated * beta + delta) * lr, updated
499
+
500
+
501
+ def grace_window(
502
+ estimates: Estimates, in_flight: int, gap: int, overlap_steps: int | None
503
+ ) -> float:
504
+ """Seconds to keep collecting after quorum.
505
+
506
+ ``min(0.5 · max(ahead·ξ_step - (ξ_quorum + ξ_sync), 0), ξ_round)`` with ``ξ_round =
507
+ gap·ξ_step`` and ``ahead`` the steps a learner trains past a publish before it would stall:
508
+ ``in_flight`` rounds of ``gap`` steps, or ``overlap_steps`` when that is less. Half the slack
509
+ those steps leave, never more than one round of training, and zero while any estimate is
510
+ still missing. The paper states the slack in compute steps because it synchronizes a
511
+ fragment every step (H = P); a round here spans ``gap`` steps of the step time learners
512
+ report.
513
+ """
514
+ if estimates.step is None or estimates.quorum is None or estimates.sync is None:
515
+ return 0.0
516
+ ahead = in_flight * gap if overlap_steps is None else min(in_flight * gap, overlap_steps)
517
+ slack = max(ahead * estimates.step - (estimates.quorum + estimates.sync), 0.0)
518
+ return min(0.5 * slack, gap * estimates.step)
519
+
520
+
521
+ _NORM_FLOOR = 1e-12
gradientmail/clock.py ADDED
@@ -0,0 +1,22 @@
1
+ """Time source for polling, backoff and timing measurements; faked in tests."""
2
+
3
+ import asyncio
4
+ import time
5
+ from typing import Protocol, runtime_checkable
6
+
7
+
8
+ @runtime_checkable
9
+ class Clock(Protocol):
10
+ """Monotonic seconds plus an awaitable sleep, so runtimes never read wall time directly."""
11
+
12
+ def now(self) -> float: ...
13
+
14
+ async def sleep(self, seconds: float) -> None: ...
15
+
16
+
17
+ class MonotonicClock(Clock):
18
+ def now(self) -> float:
19
+ return time.monotonic()
20
+
21
+ async def sleep(self, seconds: float) -> None:
22
+ await asyncio.sleep(seconds)
@@ -0,0 +1,80 @@
1
+ """Payload codecs: how a fragment's tensors become the bytes stored under a payload key.
2
+
3
+ ``off`` stores the safetensors blob as is. ``zstd`` byte-shuffles every tensor first (the i-th
4
+ byte of every element side by side, so the exponent bytes form one run and the mantissa bytes
5
+ another; a dtype of one byte or less is left as is) and compresses the blob with zstandard at
6
+ level 1: measured on GPT-2's weights, 16% smaller in fp32 and 31% in bf16 at over 1 GB/s,
7
+ which is the entropy of the exponent bytes and all a lossless codec gets from dense floats.
8
+ The key's extension names the codec, so a reader follows the writer without agreement; it
9
+ needs the ``zstd`` extra only to read a compressed payload.
10
+ """
11
+
12
+ from collections.abc import Mapping
13
+ from types import ModuleType
14
+ from typing import Literal, assert_never
15
+
16
+ from gradientmail.adapters import MissingExtra
17
+ from gradientmail.serialization import ITEM_BITS, TensorBytes, pack, unpack
18
+
19
+ __all__ = ["EXTENSION", "Compression", "compression_of", "decode", "encode"]
20
+
21
+ type Compression = Literal["off", "zstd"]
22
+
23
+ EXTENSION: Mapping[Compression, str] = {"off": ".safetensors", "zstd": ".safetensors.zst"}
24
+
25
+
26
+ def compression_of(key: str) -> Compression | None:
27
+ """The codec a payload key's extension names; None when it names none."""
28
+ for compression, extension in EXTENSION.items():
29
+ if key.endswith(extension):
30
+ return compression
31
+ return None
32
+
33
+
34
+ def encode(tensors: Mapping[str, TensorBytes], compression: Compression) -> bytes:
35
+ match compression:
36
+ case "off":
37
+ return pack(tensors)
38
+ case "zstd":
39
+ shuffled = {name: _shuffled(t) for name, t in tensors.items()}
40
+ return _zstandard().ZstdCompressor(level=1).compress(pack(shuffled))
41
+ case _:
42
+ assert_never(compression)
43
+
44
+
45
+ def decode(blob: bytes, compression: Compression) -> Mapping[str, TensorBytes]:
46
+ match compression:
47
+ case "off":
48
+ return unpack(blob)
49
+ case "zstd":
50
+ shuffled = unpack(_zstandard().ZstdDecompressor().decompress(blob))
51
+ return {name: _unshuffled(t) for name, t in shuffled.items()}
52
+ case _:
53
+ assert_never(compression)
54
+
55
+
56
+ def _shuffled(t: TensorBytes) -> TensorBytes:
57
+ size = _item_size(t)
58
+ return TensorBytes(t.dtype, t.shape, b"".join(t.data[i::size] for i in range(size)))
59
+
60
+
61
+ def _unshuffled(t: TensorBytes) -> TensorBytes:
62
+ size = _item_size(t)
63
+ count = len(t.data) // size
64
+ data = bytearray(len(t.data))
65
+ for i in range(size):
66
+ data[i::size] = t.data[i * count : (i + 1) * count]
67
+ return TensorBytes(t.dtype, t.shape, bytes(data))
68
+
69
+
70
+ def _item_size(t: TensorBytes) -> int:
71
+ """Bytes per element, one for the formats of a byte or less, which shuffle to themselves."""
72
+ return max(ITEM_BITS[t.dtype] // 8, 1)
73
+
74
+
75
+ def _zstandard() -> ModuleType:
76
+ try:
77
+ import zstandard
78
+ except ImportError as exc:
79
+ raise MissingExtra("zstd") from exc
80
+ return zstandard