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