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,279 @@
|
|
|
1
|
+
"""Pluggable synchronization algorithms: the protocols runtimes drive and the records they share."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
4
|
+
from dataclasses import dataclass, replace
|
|
5
|
+
from typing import Protocol, Self, assert_never, runtime_checkable
|
|
6
|
+
|
|
7
|
+
from gradientmail.adapters import Backend, Value
|
|
8
|
+
from gradientmail.serialization import (
|
|
9
|
+
DType,
|
|
10
|
+
ParameterSpec,
|
|
11
|
+
PayloadDType,
|
|
12
|
+
Quantized,
|
|
13
|
+
Schema,
|
|
14
|
+
TensorBytes,
|
|
15
|
+
quantized_specs,
|
|
16
|
+
scale_name,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Aggregator",
|
|
21
|
+
"Algorithm",
|
|
22
|
+
"Contribution",
|
|
23
|
+
"Estimates",
|
|
24
|
+
"FragmentState",
|
|
25
|
+
"Participant",
|
|
26
|
+
"Precision",
|
|
27
|
+
"RoundResult",
|
|
28
|
+
"RoundSchedule",
|
|
29
|
+
"decoded",
|
|
30
|
+
"encoded",
|
|
31
|
+
"layout",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
type Precision = DType | Quantized | None
|
|
35
|
+
"""How a tensor travels in a payload: in a parameter dtype, quantized with its scales beside
|
|
36
|
+
it, or, with ``None``, in its own dtype."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class RoundSchedule:
|
|
41
|
+
"""Which fragment each round synchronizes and at which global step.
|
|
42
|
+
|
|
43
|
+
Round ``r`` targets fragment ``r mod P`` at step ``(r div P)·H + ⌊((r mod P) + 1)·H / P⌋``,
|
|
44
|
+
so every fragment is synchronized exactly once per ``H`` global steps and the gaps between
|
|
45
|
+
consecutive rounds sum to ``H``. The only requirement is ``P ≤ H``. A learner keeps at most
|
|
46
|
+
``in_flight`` rounds outstanding and, with ``overlap_steps``, trains at most that many steps
|
|
47
|
+
past publishing a round before it waits for the round's commit.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
sync_every: int
|
|
51
|
+
fragments: int
|
|
52
|
+
in_flight: int
|
|
53
|
+
overlap_steps: int | None = None
|
|
54
|
+
|
|
55
|
+
def fragment_of(self, round: int) -> int:
|
|
56
|
+
return round % self.fragments
|
|
57
|
+
|
|
58
|
+
def step_of(self, round: int) -> int:
|
|
59
|
+
cycle, position = divmod(round, self.fragments)
|
|
60
|
+
return cycle * self.sync_every + (position + 1) * self.sync_every // self.fragments
|
|
61
|
+
|
|
62
|
+
def version_after(self, round: int) -> int:
|
|
63
|
+
return round // self.fragments + 1
|
|
64
|
+
|
|
65
|
+
def gap(self, round: int) -> int:
|
|
66
|
+
return self.step_of(round) - self.step_of(round - 1)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class FragmentState:
|
|
71
|
+
"""A fragment as last committed: its version, the published anchor and the outer state.
|
|
72
|
+
|
|
73
|
+
``outer`` holds ``momentum.<name>`` in F32 and ``master.<name>`` in F32 when the published
|
|
74
|
+
tensor is not the master: the schema dtype is not F32, or commits carry an update.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
version: int
|
|
78
|
+
theta: Mapping[str, TensorBytes]
|
|
79
|
+
outer: Mapping[str, TensorBytes]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class Contribution:
|
|
84
|
+
"""One learner's accepted delta of a fragment for one round: ``anchor - θ`` as the learner
|
|
85
|
+
computed it against the version it had aligned with, in the algorithm's delta schema.
|
|
86
|
+
``final`` when the learner published it while closing: it trains no further."""
|
|
87
|
+
|
|
88
|
+
learner: str
|
|
89
|
+
local_step: int
|
|
90
|
+
steps: int
|
|
91
|
+
tokens: int
|
|
92
|
+
step_seconds: float
|
|
93
|
+
final: bool
|
|
94
|
+
tensors: Mapping[str, TensorBytes]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class Participant:
|
|
99
|
+
"""A contribution as recorded in the round record, with the weight it received."""
|
|
100
|
+
|
|
101
|
+
learner: str
|
|
102
|
+
local_step: int
|
|
103
|
+
steps: int
|
|
104
|
+
tokens: int
|
|
105
|
+
weight: float
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class Estimates:
|
|
110
|
+
"""Exponential moving averages of the round timings, carried from record to record.
|
|
111
|
+
|
|
112
|
+
``step`` is the learners' reported seconds per training step, ``quorum`` the seconds from
|
|
113
|
+
round start to quorum and ``sync`` the seconds from closing to a durable commit. Each is
|
|
114
|
+
``None`` until its first observation, which is taken as is.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
step: float | None = None
|
|
118
|
+
quorum: float | None = None
|
|
119
|
+
sync: float | None = None
|
|
120
|
+
|
|
121
|
+
def updated(
|
|
122
|
+
self,
|
|
123
|
+
*,
|
|
124
|
+
step: float | None = None,
|
|
125
|
+
quorum: float | None = None,
|
|
126
|
+
sync: float | None = None,
|
|
127
|
+
) -> Self:
|
|
128
|
+
def ema(old: float | None, new: float | None) -> float | None:
|
|
129
|
+
if new is None:
|
|
130
|
+
return old
|
|
131
|
+
if old is None:
|
|
132
|
+
return new
|
|
133
|
+
return old + _EMA_DECAY * (new - old)
|
|
134
|
+
|
|
135
|
+
return replace(
|
|
136
|
+
self,
|
|
137
|
+
step=ema(self.step, step),
|
|
138
|
+
quorum=ema(self.quorum, quorum),
|
|
139
|
+
sync=ema(self.sync, sync),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def timeout(self, poll_interval: float) -> float:
|
|
143
|
+
"""Seconds after which a round these estimates describe is presumed not to commit.
|
|
144
|
+
|
|
145
|
+
Three times the round duration they predict (the known estimates summed), never less
|
|
146
|
+
than thirty polls: a follower takes over the closer after it, and a closing learner
|
|
147
|
+
stops waiting for its last rounds after it.
|
|
148
|
+
"""
|
|
149
|
+
expected = sum(s for s in (self.step, self.quorum, self.sync) if s is not None)
|
|
150
|
+
return max(_TIMEOUT_FLOOR_POLLS * poll_interval, _TIMEOUT_FACTOR * expected)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass(frozen=True)
|
|
154
|
+
class RoundResult:
|
|
155
|
+
"""What closing a round produced: the fragment's next state, the contributions as the
|
|
156
|
+
round record lists them, ``cosine``, the mean cosine similarity between the contributions'
|
|
157
|
+
deltas taken over the whole fragment (None with fewer than two), and ``update``, the new
|
|
158
|
+
theta minus the old at the algorithm's commit precision, for learners holding the old one
|
|
159
|
+
to rebuild the new from (None when commits carry none)."""
|
|
160
|
+
|
|
161
|
+
state: FragmentState
|
|
162
|
+
participants: tuple[Participant, ...]
|
|
163
|
+
cosine: float | None
|
|
164
|
+
update: Mapping[str, TensorBytes] | None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@runtime_checkable
|
|
168
|
+
class Aggregator(Protocol):
|
|
169
|
+
"""Closes one round of one fragment; the only place tensor arithmetic happens."""
|
|
170
|
+
|
|
171
|
+
def close_round(
|
|
172
|
+
self, state: FragmentState, contributions: Sequence[Contribution]
|
|
173
|
+
) -> RoundResult: ...
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@runtime_checkable
|
|
177
|
+
class Algorithm(Protocol):
|
|
178
|
+
def schedule(self, fragments: int) -> RoundSchedule:
|
|
179
|
+
"""The round schedule for the effective fragment count produced by ``plan``."""
|
|
180
|
+
...
|
|
181
|
+
|
|
182
|
+
def plan(self, schema: Schema[DType]) -> tuple[tuple[str, ...], ...]: ...
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def delta_dtype(self) -> Precision:
|
|
186
|
+
"""The precision learners publish their deltas (``anchor - θ``) at; a snapshot of a
|
|
187
|
+
fragment must hold the ``layout`` of the fragment's schema at it."""
|
|
188
|
+
...
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def commit_dtype(self) -> Precision:
|
|
192
|
+
"""The precision a commit publishes its update (``Θ_new - Θ_old``) at, for a learner
|
|
193
|
+
holding the old version to rebuild the new one from; ``None`` publishes theta alone."""
|
|
194
|
+
...
|
|
195
|
+
|
|
196
|
+
def aggregator(
|
|
197
|
+
self, schema: Schema[DType], names: Sequence[str], backend: Backend
|
|
198
|
+
) -> Aggregator:
|
|
199
|
+
"""An aggregator for the fragment holding ``names``, computing in ``backend``."""
|
|
200
|
+
...
|
|
201
|
+
|
|
202
|
+
def quorum(self) -> int: ...
|
|
203
|
+
|
|
204
|
+
@property
|
|
205
|
+
def mixing(self) -> float:
|
|
206
|
+
"""The share of its local parameters a learner keeps when it aligns a fragment with a
|
|
207
|
+
commit: ``mixing·θ + (1 - mixing)·Θ``, in ``[0, 1)``."""
|
|
208
|
+
...
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def flush_on_close(self) -> bool:
|
|
212
|
+
"""Whether a closing learner publishes the steps trained since each fragment's last
|
|
213
|
+
round, as final snapshots, instead of only waiting for its rounds in flight."""
|
|
214
|
+
...
|
|
215
|
+
|
|
216
|
+
def global_batch(self) -> int | None:
|
|
217
|
+
"""The batch the live learners share between them; ``None`` when each sets its own."""
|
|
218
|
+
...
|
|
219
|
+
|
|
220
|
+
def to_json(self) -> Mapping[str, object]:
|
|
221
|
+
"""A JSON-serializable record of the configuration, stored in the manifest."""
|
|
222
|
+
...
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
_EMA_DECAY = 0.1
|
|
226
|
+
_TIMEOUT_FACTOR = 3.0
|
|
227
|
+
_TIMEOUT_FLOOR_POLLS = 30
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def layout(schema: Schema[DType], precision: Precision) -> Schema[PayloadDType]:
|
|
231
|
+
"""What ``schema``'s tensors travel as at ``precision``: each in that dtype, a quantized
|
|
232
|
+
one with its scales beside it (``quantized_specs``), or as it is."""
|
|
233
|
+
|
|
234
|
+
def entries(spec: ParameterSpec[DType]) -> Iterable[ParameterSpec[PayloadDType]]:
|
|
235
|
+
match precision:
|
|
236
|
+
case None:
|
|
237
|
+
return (spec,)
|
|
238
|
+
case "F32" | "F16" | "BF16" as dtype:
|
|
239
|
+
return (ParameterSpec(spec.name, dtype, spec.shape),)
|
|
240
|
+
case "F8_E4M3" | "F4" as dtype:
|
|
241
|
+
return quantized_specs(spec.name, dtype, spec.shape)
|
|
242
|
+
case _:
|
|
243
|
+
assert_never(precision)
|
|
244
|
+
|
|
245
|
+
specs = (entry for spec in schema.parameters for entry in entries(spec))
|
|
246
|
+
return Schema(tuple(sorted(specs, key=lambda spec: spec.name)))
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def encoded(
|
|
250
|
+
name: str, value: Value, dtype: DType, precision: Precision
|
|
251
|
+
) -> Mapping[str, TensorBytes]:
|
|
252
|
+
"""The payload entries the fp32 ``value`` of ``name``, a parameter of ``dtype``, travels as
|
|
253
|
+
at ``precision``."""
|
|
254
|
+
match precision:
|
|
255
|
+
case None:
|
|
256
|
+
return {name: value.to_bytes(dtype)}
|
|
257
|
+
case "F32" | "F16" | "BF16" as narrowed:
|
|
258
|
+
return {name: value.to_bytes(narrowed)}
|
|
259
|
+
case "F8_E4M3" | "F4" as quantized:
|
|
260
|
+
data, scale = value.quantize(quantized)
|
|
261
|
+
return {name: data, scale_name(name): scale}
|
|
262
|
+
case _:
|
|
263
|
+
assert_never(precision)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def decoded(
|
|
267
|
+
backend: Backend,
|
|
268
|
+
tensors: Mapping[str, TensorBytes],
|
|
269
|
+
name: str,
|
|
270
|
+
shape: tuple[int, ...],
|
|
271
|
+
precision: Precision,
|
|
272
|
+
) -> Value:
|
|
273
|
+
"""The fp32 value of ``name``, of ``shape``, from the entries ``encoded`` produced at
|
|
274
|
+
``precision``."""
|
|
275
|
+
match precision:
|
|
276
|
+
case "F8_E4M3" | "F4":
|
|
277
|
+
return backend.dequantize(tensors[name], tensors[scale_name(name)], shape)
|
|
278
|
+
case _:
|
|
279
|
+
return backend.load(tensors[name])
|