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,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])