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,241 @@
|
|
|
1
|
+
"""Tensor bytes in the safetensors format and the parameter schema they must match.
|
|
2
|
+
|
|
3
|
+
Payloads are produced by ``safetensors.serialize`` and read back by a small header
|
|
4
|
+
parser, so neither direction needs NumPy or an ML framework. Bytes are always
|
|
5
|
+
little-endian and C-contiguous: the format carries no strides, and adapters hand
|
|
6
|
+
over contiguous host copies.
|
|
7
|
+
|
|
8
|
+
Parameters are ``F32``, ``F16`` or ``BF16`` (``DType``, the formats torch and MLX share).
|
|
9
|
+
Payloads may also carry deltas quantized to ``F8_E4M3`` or ``F4`` (``Quantized``) and the
|
|
10
|
+
``F8_E8M0`` scales of the latter (``PayloadDType``). A quantized tensor travels as two
|
|
11
|
+
entries, its data under its name and its scales under ``scale_name(name)``, in the layout
|
|
12
|
+
``quantized_specs`` describes.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import ctypes
|
|
16
|
+
import hashlib
|
|
17
|
+
import itertools
|
|
18
|
+
import json
|
|
19
|
+
import math
|
|
20
|
+
import sys
|
|
21
|
+
from collections.abc import Iterable, Mapping
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from functools import cached_property
|
|
24
|
+
from typing import Literal, TypedDict, assert_never
|
|
25
|
+
|
|
26
|
+
import safetensors
|
|
27
|
+
|
|
28
|
+
if sys.byteorder != "little":
|
|
29
|
+
raise ImportError("gradientmail requires a little-endian host")
|
|
30
|
+
|
|
31
|
+
type DType = Literal["F32", "F16", "BF16"]
|
|
32
|
+
type Quantized = Literal["F8_E4M3", "F4"]
|
|
33
|
+
type PayloadDType = DType | Quantized | Literal["F8_E8M0"]
|
|
34
|
+
|
|
35
|
+
ITEM_BITS: Mapping[PayloadDType, int] = {
|
|
36
|
+
"F32": 32,
|
|
37
|
+
"F16": 16,
|
|
38
|
+
"BF16": 16,
|
|
39
|
+
"F8_E4M3": 8,
|
|
40
|
+
"F8_E8M0": 8,
|
|
41
|
+
"F4": 4,
|
|
42
|
+
}
|
|
43
|
+
SPEC_NAME: Mapping[PayloadDType, str] = {
|
|
44
|
+
"F32": "float32",
|
|
45
|
+
"F16": "float16",
|
|
46
|
+
"BF16": "bfloat16",
|
|
47
|
+
"F8_E4M3": "float8_e4m3fn",
|
|
48
|
+
"F8_E8M0": "float8_e8m0fnu",
|
|
49
|
+
"F4": "float4_e2m1fn_x2",
|
|
50
|
+
}
|
|
51
|
+
F4_BLOCK = 32
|
|
52
|
+
"""Elements per scale of an ``F4`` tensor, which is padded to a multiple of them."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def nbytes(dtype: PayloadDType, shape: tuple[int, ...]) -> int:
|
|
56
|
+
return ITEM_BITS[dtype] * math.prod(shape) // 8
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class TensorBytes:
|
|
61
|
+
"""One tensor as raw little-endian, C-contiguous bytes with its header dtype code.
|
|
62
|
+
|
|
63
|
+
An ``F4`` tensor packs two elements per byte, the first in the low nibble, so its last
|
|
64
|
+
axis must be even.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
dtype: PayloadDType
|
|
68
|
+
shape: tuple[int, ...]
|
|
69
|
+
data: bytes
|
|
70
|
+
|
|
71
|
+
def __post_init__(self) -> None:
|
|
72
|
+
if self.dtype == "F4" and (not self.shape or self.shape[-1] % 2):
|
|
73
|
+
raise ValueError(f"F4 tensor of shape {self.shape} needs an even last axis")
|
|
74
|
+
expected = nbytes(self.dtype, self.shape)
|
|
75
|
+
if len(self.data) != expected:
|
|
76
|
+
raise ValueError(
|
|
77
|
+
f"{self.dtype} tensor of shape {self.shape} needs {expected} bytes, "
|
|
78
|
+
f"got {len(self.data)}"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class ParameterSpec[D: PayloadDType]:
|
|
84
|
+
name: str
|
|
85
|
+
dtype: D
|
|
86
|
+
shape: tuple[int, ...]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class Schema[D: PayloadDType]:
|
|
91
|
+
"""Named tensors with their dtypes and shapes, sorted by name with unique names: a model's
|
|
92
|
+
canonical trainable parameters (``Schema[DType]``), or what a payload must hold."""
|
|
93
|
+
|
|
94
|
+
parameters: tuple[ParameterSpec[D], ...]
|
|
95
|
+
|
|
96
|
+
def __post_init__(self) -> None:
|
|
97
|
+
if any(a.name >= b.name for a, b in itertools.pairwise(self.parameters)):
|
|
98
|
+
raise ValueError("schema parameters must be sorted by name and unique")
|
|
99
|
+
|
|
100
|
+
def digest(self) -> str:
|
|
101
|
+
entries = [[p.name, p.dtype, list(p.shape)] for p in self.parameters]
|
|
102
|
+
return hashlib.sha256(json.dumps(entries, separators=(",", ":")).encode()).hexdigest()
|
|
103
|
+
|
|
104
|
+
def nbytes(self, name: str) -> int:
|
|
105
|
+
spec = self._by_name[name]
|
|
106
|
+
return nbytes(spec.dtype, spec.shape)
|
|
107
|
+
|
|
108
|
+
@cached_property
|
|
109
|
+
def _by_name(self) -> Mapping[str, ParameterSpec[D]]:
|
|
110
|
+
return {p.name: p for p in self.parameters}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def scale_name(name: str) -> str:
|
|
114
|
+
"""The payload entry holding the scales of the quantized tensor under ``name``."""
|
|
115
|
+
return f"{name}.scale"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def quantized_specs(
|
|
119
|
+
name: str, dtype: Quantized, shape: tuple[int, ...]
|
|
120
|
+
) -> tuple[ParameterSpec[PayloadDType], ParameterSpec[PayloadDType]]:
|
|
121
|
+
"""The two entries a tensor of ``shape`` quantized to ``dtype`` travels as: its data under
|
|
122
|
+
``name`` and its scales, powers of two, under ``scale_name(name)``.
|
|
123
|
+
|
|
124
|
+
``F8_E4M3`` keeps the shape and has one F32 scale for the whole tensor. ``F4`` is
|
|
125
|
+
flattened and zero-padded to a multiple of ``F4_BLOCK`` elements, with one F8_E8M0 scale
|
|
126
|
+
per block, as the MX formats lay it out.
|
|
127
|
+
"""
|
|
128
|
+
match dtype:
|
|
129
|
+
case "F8_E4M3":
|
|
130
|
+
return ParameterSpec(name, dtype, shape), ParameterSpec(scale_name(name), "F32", ())
|
|
131
|
+
case "F4":
|
|
132
|
+
blocks = -(-math.prod(shape) // F4_BLOCK)
|
|
133
|
+
return (
|
|
134
|
+
ParameterSpec(name, dtype, (blocks * F4_BLOCK,)),
|
|
135
|
+
ParameterSpec(scale_name(name), "F8_E8M0", (blocks,)),
|
|
136
|
+
)
|
|
137
|
+
case _:
|
|
138
|
+
assert_never(dtype)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class SchemaMismatch(ValueError):
|
|
142
|
+
"""Tensors do not match the schema; ``names`` lists the offending parameters."""
|
|
143
|
+
|
|
144
|
+
names: tuple[str, ...]
|
|
145
|
+
|
|
146
|
+
def __init__(
|
|
147
|
+
self, names: Iterable[str], message: str = "parameters do not match the schema"
|
|
148
|
+
) -> None:
|
|
149
|
+
self.names = tuple(names)
|
|
150
|
+
super().__init__(f"{message}: {', '.join(self.names)}")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def validate(
|
|
154
|
+
schema: Schema[PayloadDType], tensors: Mapping[str, TensorBytes], names: Iterable[str]
|
|
155
|
+
) -> None:
|
|
156
|
+
"""Raise ``SchemaMismatch`` unless every name is present with the schema's dtype and shape."""
|
|
157
|
+
|
|
158
|
+
def matches(name: str) -> bool:
|
|
159
|
+
spec = specs.get(name)
|
|
160
|
+
tensor = tensors.get(name)
|
|
161
|
+
return (
|
|
162
|
+
spec is not None
|
|
163
|
+
and tensor is not None
|
|
164
|
+
and (tensor.dtype, tensor.shape) == (spec.dtype, spec.shape)
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
specs = {p.name: p for p in schema.parameters}
|
|
168
|
+
offending = tuple(name for name in names if not matches(name))
|
|
169
|
+
if offending:
|
|
170
|
+
raise SchemaMismatch(offending)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def pack(tensors: Mapping[str, TensorBytes]) -> bytes:
|
|
174
|
+
"""Serialize to safetensors bytes without metadata; deterministic for equal content."""
|
|
175
|
+
|
|
176
|
+
def address(data: bytes) -> int:
|
|
177
|
+
# typeshed's ctypes.cast rejects bytes and c_void_p.value is Optional; this is the
|
|
178
|
+
# same address as ctypes.cast(data, c_void_p).value with an honest int type.
|
|
179
|
+
return ctypes.addressof(
|
|
180
|
+
ctypes.cast(ctypes.c_char_p(data), ctypes.POINTER(ctypes.c_char)).contents
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
def shape(t: TensorBytes) -> list[int]:
|
|
184
|
+
# The F4 spec counts pairs along the last axis; the header records the elements.
|
|
185
|
+
if t.dtype == "F4":
|
|
186
|
+
return [*t.shape[:-1], t.shape[-1] // 2]
|
|
187
|
+
return list(t.shape)
|
|
188
|
+
|
|
189
|
+
# `tensors` keeps every buffer alive for the whole call, which the pointer API requires.
|
|
190
|
+
specs = {
|
|
191
|
+
name: safetensors.TensorSpec(
|
|
192
|
+
dtype=SPEC_NAME[t.dtype],
|
|
193
|
+
shape=shape(t),
|
|
194
|
+
data_ptr=address(t.data),
|
|
195
|
+
data_len=len(t.data),
|
|
196
|
+
)
|
|
197
|
+
for name, t in tensors.items()
|
|
198
|
+
}
|
|
199
|
+
return safetensors.serialize(specs, metadata=None)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def unpack(blob: bytes) -> Mapping[str, TensorBytes]:
|
|
203
|
+
"""Read safetensors bytes, ignoring ``__metadata__``.
|
|
204
|
+
|
|
205
|
+
Raises ``ValueError`` when the header overruns the blob, an offset lies outside it,
|
|
206
|
+
or a dtype code is not a ``PayloadDType``.
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
def dtype_of(code: str) -> PayloadDType:
|
|
210
|
+
match code:
|
|
211
|
+
case "F32" | "F16" | "BF16" | "F8_E4M3" | "F8_E8M0" | "F4":
|
|
212
|
+
return code
|
|
213
|
+
case _:
|
|
214
|
+
raise ValueError(f"unsupported dtype code {code!r}")
|
|
215
|
+
|
|
216
|
+
def tensor(entry: _HeaderEntry) -> TensorBytes:
|
|
217
|
+
start, stop = entry["data_offsets"]
|
|
218
|
+
if not 0 <= start <= stop <= len(blob) - base:
|
|
219
|
+
raise ValueError("tensor offsets outside the blob")
|
|
220
|
+
return TensorBytes(
|
|
221
|
+
dtype_of(entry["dtype"]),
|
|
222
|
+
tuple(entry["shape"]),
|
|
223
|
+
view[base + start : base + stop].tobytes(),
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
base = 8 + int.from_bytes(blob[:8], "little")
|
|
227
|
+
if base > len(blob):
|
|
228
|
+
raise ValueError("header overruns the blob")
|
|
229
|
+
view = memoryview(blob)
|
|
230
|
+
header: Mapping[str, _HeaderEntry] = {
|
|
231
|
+
name: entry
|
|
232
|
+
for name, entry in json.loads(view[8:base].tobytes()).items()
|
|
233
|
+
if name != "__metadata__"
|
|
234
|
+
}
|
|
235
|
+
return {name: tensor(entry) for name, entry in header.items()}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class _HeaderEntry(TypedDict):
|
|
239
|
+
dtype: str
|
|
240
|
+
shape: list[int]
|
|
241
|
+
data_offsets: list[int]
|
gradientmail/session.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""The public facade: one object wraps a training loop from join to close, learner and syncer.
|
|
2
|
+
|
|
3
|
+
``Session`` resolves the framework backend from the model, names the node after the host and
|
|
4
|
+
process when the caller does not, joins the run before returning so the model holds the newest
|
|
5
|
+
global parameters by the time training starts, and then runs the learner's communication loop
|
|
6
|
+
and the syncer on two daemon threads. Every session takes part in closing rounds; which one
|
|
7
|
+
does the work is settled through the mailbox (``syncer`` module), so a run has no separate
|
|
8
|
+
process to operate.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import socket
|
|
15
|
+
import threading
|
|
16
|
+
from collections.abc import Sequence
|
|
17
|
+
from typing import Self
|
|
18
|
+
|
|
19
|
+
from gradientmail.adapters import Adapter, Backend, adapter_for, backend_for, default_backend
|
|
20
|
+
from gradientmail.algorithms import Algorithm
|
|
21
|
+
from gradientmail.clock import MonotonicClock
|
|
22
|
+
from gradientmail.compression import Compression
|
|
23
|
+
from gradientmail.events import Callback, SyncerStopped, combined, log_events
|
|
24
|
+
from gradientmail.learner import Learner
|
|
25
|
+
from gradientmail.learner import align as align_newest
|
|
26
|
+
from gradientmail.mailbox import Mailbox
|
|
27
|
+
from gradientmail.protocol import Exchange, ProtocolError
|
|
28
|
+
from gradientmail.syncer import Syncer
|
|
29
|
+
|
|
30
|
+
__all__ = ["Session", "align"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def align(model: object, *, mailbox: Mailbox) -> int:
|
|
34
|
+
"""Load the run's newest committed parameters into ``model`` without joining the run.
|
|
35
|
+
|
|
36
|
+
For evaluation or inference: nothing is published and no claim is made. Returns the newest
|
|
37
|
+
committed round, -1 when the run has committed none yet; raises ``ProtocolError`` when
|
|
38
|
+
``mailbox`` holds no run and ``SchemaMismatch`` when ``model`` does not match it.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
async def read() -> int:
|
|
42
|
+
exchange = Exchange(mailbox, clock=MonotonicClock())
|
|
43
|
+
manifest = await exchange.manifest()
|
|
44
|
+
if manifest is None:
|
|
45
|
+
raise ProtocolError("the mailbox holds no run")
|
|
46
|
+
records = (await align_newest(adapter_for(model), exchange, manifest)).records
|
|
47
|
+
return records[-1].round if records else -1
|
|
48
|
+
|
|
49
|
+
return asyncio.run(read())
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Session:
|
|
53
|
+
"""One node of a run, wrapped around the caller's own training loop.
|
|
54
|
+
|
|
55
|
+
Construction joins the run, creating it from the model's parameters when it does not exist
|
|
56
|
+
yet, and returns only once the model holds the newest committed version of every fragment;
|
|
57
|
+
the communication and syncer threads run from then on. Call ``step`` after every optimizer
|
|
58
|
+
step and ``close`` when training ends, or use the instance as a context manager. ``close``
|
|
59
|
+
publishes what was trained since the last rounds and waits for those rounds to commit, so
|
|
60
|
+
the run's parameters end with this node's last steps.
|
|
61
|
+
|
|
62
|
+
Parameters
|
|
63
|
+
----------
|
|
64
|
+
model
|
|
65
|
+
A torch or MLX module, or any ``Adapter``.
|
|
66
|
+
algorithm
|
|
67
|
+
The run's algorithm; its configuration must equal the one the run was created with.
|
|
68
|
+
mailbox
|
|
69
|
+
The store the run lives in.
|
|
70
|
+
learner_id
|
|
71
|
+
``[a-z0-9-]{1,64}``. Defaults to the host name and process id, so two processes on one
|
|
72
|
+
host never share an id.
|
|
73
|
+
poll_interval
|
|
74
|
+
Seconds between polls for new round records and snapshots.
|
|
75
|
+
backend
|
|
76
|
+
Where this node's deltas and, when it closes rounds, outer steps are computed. Defaults
|
|
77
|
+
to the model's framework, or to the installed one (torch, then MLX) when ``model`` is an
|
|
78
|
+
``Adapter``.
|
|
79
|
+
compression
|
|
80
|
+
The codec this node's payloads are stored with: ``"off"`` for safetensors as is, or
|
|
81
|
+
``"zstd"`` for byte-shuffled zstandard (the ``zstd`` extra), 16% smaller for fp32 and
|
|
82
|
+
31% for bf16. Every node reads every codec; nodes of one run may differ.
|
|
83
|
+
lease_every, lease_timeout
|
|
84
|
+
Seconds between renewals of this node's lease, and seconds without a renewal after
|
|
85
|
+
which the other nodes stop waiting for it. A round closes at ``quorum_size`` snapshots
|
|
86
|
+
or once every live node has reported, and a closing node releases its lease at once, so
|
|
87
|
+
nodes join and leave without stalling the run.
|
|
88
|
+
callbacks
|
|
89
|
+
What receives the node's events (``events`` module), each in turn, on the thread that
|
|
90
|
+
produced the event; one that raises is logged and does not affect the run or the other
|
|
91
|
+
callbacks. The default logs them; leaving ``log_events`` out silences the node's log.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
model: object,
|
|
97
|
+
*,
|
|
98
|
+
algorithm: Algorithm,
|
|
99
|
+
mailbox: Mailbox,
|
|
100
|
+
learner_id: str | None = None,
|
|
101
|
+
poll_interval: float = 1.0,
|
|
102
|
+
backend: Backend | None = None,
|
|
103
|
+
compression: Compression = "off",
|
|
104
|
+
lease_every: float = 60.0,
|
|
105
|
+
lease_timeout: float = 120.0,
|
|
106
|
+
callbacks: Sequence[Callback] = (log_events,),
|
|
107
|
+
) -> None:
|
|
108
|
+
def default_learner_id() -> str:
|
|
109
|
+
pid = f"-{os.getpid()}"
|
|
110
|
+
host = re.sub(r"[^a-z0-9-]", "-", socket.gethostname().lower())
|
|
111
|
+
return f"{host[: 64 - len(pid)]}{pid}"
|
|
112
|
+
|
|
113
|
+
def synchronize() -> None:
|
|
114
|
+
try:
|
|
115
|
+
asyncio.run(self._syncer.run())
|
|
116
|
+
except asyncio.CancelledError:
|
|
117
|
+
pass
|
|
118
|
+
except BaseException as exc:
|
|
119
|
+
on_event(SyncerStopped(learner_id, exc))
|
|
120
|
+
self._failure = exc
|
|
121
|
+
|
|
122
|
+
if backend is None:
|
|
123
|
+
backend = default_backend() if isinstance(model, Adapter) else backend_for(model)
|
|
124
|
+
adapter = model if isinstance(model, Adapter) else backend.adapter(model)
|
|
125
|
+
if learner_id is None:
|
|
126
|
+
learner_id = default_learner_id()
|
|
127
|
+
on_event = combined(*callbacks)
|
|
128
|
+
self._learner = Learner(
|
|
129
|
+
adapter=adapter,
|
|
130
|
+
algorithm=algorithm,
|
|
131
|
+
mailbox=mailbox,
|
|
132
|
+
learner_id=learner_id,
|
|
133
|
+
backend=backend,
|
|
134
|
+
poll_interval=poll_interval,
|
|
135
|
+
lease_every=lease_every,
|
|
136
|
+
lease_timeout=lease_timeout,
|
|
137
|
+
compression=compression,
|
|
138
|
+
on_event=on_event,
|
|
139
|
+
)
|
|
140
|
+
self._syncer = Syncer(
|
|
141
|
+
algorithm=algorithm,
|
|
142
|
+
mailbox=mailbox,
|
|
143
|
+
learner_id=learner_id,
|
|
144
|
+
backend=backend,
|
|
145
|
+
poll_interval=poll_interval,
|
|
146
|
+
lease_timeout=lease_timeout,
|
|
147
|
+
compression=compression,
|
|
148
|
+
on_event=on_event,
|
|
149
|
+
)
|
|
150
|
+
self._failure: BaseException | None = None
|
|
151
|
+
self._learner.start()
|
|
152
|
+
self._thread = threading.Thread(
|
|
153
|
+
target=synchronize, name=f"gradientmail-syncer-{learner_id}", daemon=True
|
|
154
|
+
)
|
|
155
|
+
self._thread.start()
|
|
156
|
+
|
|
157
|
+
def step(self, *, samples: int) -> None:
|
|
158
|
+
"""Account for one training step of ``samples`` tokens; never awaits or does I/O.
|
|
159
|
+
|
|
160
|
+
Blocks only when the algorithm bounds ``overlap_steps`` and a round published that many
|
|
161
|
+
steps ago is still outstanding. Raises what either background thread died of.
|
|
162
|
+
"""
|
|
163
|
+
if self._failure is not None:
|
|
164
|
+
raise self._failure
|
|
165
|
+
self._learner.step(samples)
|
|
166
|
+
|
|
167
|
+
def close(self) -> None:
|
|
168
|
+
"""Wait for the learner's rounds in flight to commit (``Learner.close``), stop both
|
|
169
|
+
threads.
|
|
170
|
+
|
|
171
|
+
The syncer keeps closing rounds until the learner is done, so a node closing alone
|
|
172
|
+
still commits what it published when quorum allows.
|
|
173
|
+
"""
|
|
174
|
+
try:
|
|
175
|
+
self._learner.close()
|
|
176
|
+
finally:
|
|
177
|
+
self._syncer.stop()
|
|
178
|
+
self._thread.join()
|
|
179
|
+
|
|
180
|
+
def __enter__(self) -> Self:
|
|
181
|
+
return self
|
|
182
|
+
|
|
183
|
+
def __exit__(self, *exc: object) -> None:
|
|
184
|
+
self.close()
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def rounds_seen(self) -> int:
|
|
188
|
+
"""The newest round whose committed fragment the model holds; -1 on a fresh run."""
|
|
189
|
+
return self._learner.rounds_seen
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def batch_size(self) -> int | None:
|
|
193
|
+
"""This node's share of the algorithm's global batch, for the training loop to size its
|
|
194
|
+
batches by; it changes as nodes join and leave. ``None`` when the algorithm sets none."""
|
|
195
|
+
return self._learner.batch_size
|