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