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,59 @@
1
+ """GradientMail: distributed training through asynchronous message exchange.
2
+
3
+ ``import gradientmail`` needs only the standard library and safetensors. ``Session`` wraps a
4
+ training loop; every session also takes part in closing rounds, so a run has no separate
5
+ process to operate. What a node reports while it runs is in ``gradientmail.events``, handed
6
+ to the session's ``callbacks``. ``S3Mailbox`` is resolved on first access and raises
7
+ ``MissingExtra`` naming the ``s3`` extra when boto3 is absent. The framework backends live in
8
+ ``gradientmail.adapters.torch`` and ``gradientmail.adapters.mlx``.
9
+ """
10
+
11
+ from typing import TYPE_CHECKING
12
+
13
+ from gradientmail.adapters import Adapter, Backend, MemoryAdapter, MissingExtra
14
+ from gradientmail.algorithms import Algorithm
15
+ from gradientmail.algorithms.diloco import ByBytes, DiLoCo, Rda, Strided, WeightedMean
16
+ from gradientmail.events import Callback, Event, log_events
17
+ from gradientmail.mailbox import Mailbox, MailboxUnavailable
18
+ from gradientmail.mailbox.memory import MemoryMailbox
19
+ from gradientmail.protocol import RunConfigMismatch
20
+ from gradientmail.serialization import Schema, SchemaMismatch, TensorBytes
21
+ from gradientmail.session import Session, align
22
+
23
+ if TYPE_CHECKING:
24
+ from gradientmail.mailbox.s3 import S3Mailbox as S3Mailbox
25
+
26
+ __all__ = [
27
+ "Adapter",
28
+ "Algorithm",
29
+ "Backend",
30
+ "ByBytes",
31
+ "Callback",
32
+ "DiLoCo",
33
+ "Event",
34
+ "Mailbox",
35
+ "MailboxUnavailable",
36
+ "MemoryAdapter",
37
+ "MemoryMailbox",
38
+ "MissingExtra",
39
+ "Rda",
40
+ "RunConfigMismatch",
41
+ "Schema",
42
+ "SchemaMismatch",
43
+ "Session",
44
+ "Strided",
45
+ "TensorBytes",
46
+ "WeightedMean",
47
+ "align",
48
+ "log_events",
49
+ ]
50
+
51
+
52
+ def __getattr__(name: str) -> object:
53
+ if name == "S3Mailbox":
54
+ try:
55
+ from gradientmail.mailbox.s3 import S3Mailbox
56
+ except ImportError as exc:
57
+ raise MissingExtra("s3") from exc
58
+ return S3Mailbox
59
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,174 @@
1
+ """Framework backends: how a model's parameters become bytes, and how bytes become arithmetic.
2
+
3
+ A ``Backend`` is everything GradientMail knows about one framework. On a learner it wraps a
4
+ model in an ``Adapter``, the three-method byte boundary (``schema``, ``snapshot``, ``apply``);
5
+ on a syncer it loads ``TensorBytes`` as ``Value``s, the handful of fp32 operations the outer
6
+ step is made of, which serialize themselves back. ``TorchBackend`` and ``MlxBackend`` live
7
+ in their own modules and are imported only when asked for, so this package needs nothing
8
+ beyond the standard library. Validation against the schema is the
9
+ caller's job (``Learner`` before every ``apply``, ``Syncer`` before merging), so adapters
10
+ stay thin.
11
+ """
12
+
13
+ from collections.abc import Iterable, Mapping
14
+ from importlib.util import find_spec
15
+ from typing import Protocol, Self, runtime_checkable
16
+
17
+ from gradientmail.serialization import (
18
+ DType,
19
+ ParameterSpec,
20
+ Quantized,
21
+ Schema,
22
+ SchemaMismatch,
23
+ TensorBytes,
24
+ )
25
+
26
+ __all__ = [
27
+ "Adapter",
28
+ "Backend",
29
+ "MemoryAdapter",
30
+ "MissingExtra",
31
+ "Value",
32
+ "adapter_for",
33
+ "backend_for",
34
+ "default_backend",
35
+ ]
36
+
37
+
38
+ class MissingExtra(ImportError):
39
+ """An optional dependency is not installed; ``extra`` names the extra that provides it."""
40
+
41
+ extra: str
42
+
43
+ def __init__(self, extra: str) -> None:
44
+ self.extra = extra
45
+ super().__init__(f"this needs the {extra!r} extra: pip install 'gradientmail[{extra}]'")
46
+
47
+
48
+ @runtime_checkable
49
+ class Adapter(Protocol):
50
+ def schema(self) -> Schema[DType]:
51
+ """Canonical trainable parameters, sorted by name."""
52
+ ...
53
+
54
+ def snapshot(self, names: Iterable[str]) -> Mapping[str, TensorBytes]:
55
+ """Owned copies of ``names``, consistent at call time."""
56
+ ...
57
+
58
+ def apply(self, tensors: Mapping[str, TensorBytes]) -> None:
59
+ """Overwrite parameters in place; never rebinds them, so optimizer state survives."""
60
+ ...
61
+
62
+
63
+ @runtime_checkable
64
+ class Value(Protocol):
65
+ """An fp32 tensor held by a backend, with the arithmetic the outer step is made of.
66
+
67
+ Every operation returns a new value of the same backend. ``norm`` is the Euclidean norm
68
+ over all elements and ``dot`` the sum of the elementwise products with ``other``.
69
+ ``to_bytes`` rounds to ``dtype`` (nearest even) and serializes; ``quantize`` serializes to
70
+ a ``Quantized`` format as ``(data, scales)`` in the layout ``serialization.quantized_specs``
71
+ describes, each scale a power of two that puts the largest magnitude of its tensor or
72
+ block at the top of the format, so dequantizing is exact in fp32.
73
+ """
74
+
75
+ def __add__(self, other: Self) -> Self: ...
76
+
77
+ def __sub__(self, other: Self) -> Self: ...
78
+
79
+ def __mul__(self, scalar: float) -> Self: ...
80
+
81
+ def norm(self) -> float: ...
82
+
83
+ def dot(self, other: Self) -> float: ...
84
+
85
+ def to_bytes(self, dtype: DType) -> TensorBytes: ...
86
+
87
+ def quantize(self, dtype: Quantized) -> tuple[TensorBytes, TensorBytes]: ...
88
+
89
+
90
+ @runtime_checkable
91
+ class Backend(Protocol):
92
+ """One framework: it adapts that framework's models and does the syncer's arithmetic."""
93
+
94
+ def adapter(self, model: object) -> Adapter:
95
+ """An Adapter over ``model``; ``TypeError`` when it is not this framework's model."""
96
+ ...
97
+
98
+ def load(self, tensor: TensorBytes) -> Value:
99
+ """``tensor`` as an fp32 value of its shape; ``ValueError`` for a quantized dtype,
100
+ whose bytes mean nothing without their scales."""
101
+ ...
102
+
103
+ def dequantize(self, data: TensorBytes, scale: TensorBytes, shape: tuple[int, ...]) -> Value:
104
+ """The fp32 value of ``shape`` whose ``quantize`` gave ``(data, scale)``, up to the
105
+ format's rounding; the same floats on every backend."""
106
+ ...
107
+
108
+
109
+ class MemoryAdapter(Adapter):
110
+ """An Adapter over a dict of ``TensorBytes``: the Phase 2 learner and the fake in tests."""
111
+
112
+ def __init__(self, tensors: Mapping[str, TensorBytes]) -> None:
113
+ self._tensors = dict(tensors)
114
+
115
+ @property
116
+ def tensors(self) -> Mapping[str, TensorBytes]:
117
+ return self._tensors
118
+
119
+ def schema(self) -> Schema[DType]:
120
+ def spec(name: str, t: TensorBytes) -> ParameterSpec[DType]:
121
+ match t.dtype:
122
+ case "F32" | "F16" | "BF16" as dtype:
123
+ return ParameterSpec(name, dtype, t.shape)
124
+ case _:
125
+ raise SchemaMismatch([name], "parameter dtype is not F32, F16 or BF16")
126
+
127
+ return Schema(tuple(spec(name, t) for name, t in sorted(self._tensors.items())))
128
+
129
+ def snapshot(self, names: Iterable[str]) -> Mapping[str, TensorBytes]:
130
+ return {name: self._tensors[name] for name in names}
131
+
132
+ def apply(self, tensors: Mapping[str, TensorBytes]) -> None:
133
+ self._tensors.update(tensors)
134
+
135
+
136
+ def backend_for(model: object) -> Backend:
137
+ """The backend of ``model``'s framework, read from the modules of ``type(model)``'s MRO.
138
+
139
+ A user's ``class MLP(nn.Module)`` lives in the user's module, so the whole MRO is searched;
140
+ no framework is imported to decide. Anything else is a ``TypeError``.
141
+ """
142
+ roots = {cls.__module__.partition(".")[0] for cls in type(model).__mro__}
143
+ if "torch" in roots:
144
+ from gradientmail.adapters.torch import TorchBackend
145
+
146
+ return TorchBackend()
147
+ if "mlx" in roots:
148
+ from gradientmail.adapters.mlx import MlxBackend
149
+
150
+ return MlxBackend()
151
+ raise TypeError(
152
+ f"no backend for {type(model).__module__}.{type(model).__qualname__}; "
153
+ "pass a torch or MLX module, or an Adapter"
154
+ )
155
+
156
+
157
+ def adapter_for(model: object) -> Adapter:
158
+ """``model`` itself when it is an Adapter; otherwise ``backend_for(model).adapter(model)``."""
159
+ if isinstance(model, Adapter):
160
+ return model
161
+ return backend_for(model).adapter(model)
162
+
163
+
164
+ def default_backend() -> Backend:
165
+ """The installed backend: torch, then MLX. ``MissingExtra("torch")`` when neither is."""
166
+ if find_spec("torch") is not None:
167
+ from gradientmail.adapters.torch import TorchBackend
168
+
169
+ return TorchBackend()
170
+ if find_spec("mlx") is not None:
171
+ from gradientmail.adapters.mlx import MlxBackend
172
+
173
+ return MlxBackend()
174
+ raise MissingExtra("torch")
@@ -0,0 +1,252 @@
1
+ """MLX backend: the trainable arrays of an ``mlx.nn.Module`` as ``TensorBytes`` and back, and
2
+ the outer step on ``mx.array``.
3
+
4
+ Parameter names are the dotted paths of ``model.trainable_parameters()`` (``fc1.weight``,
5
+ ``layers.0.bias``), the names ``mlx.utils.tree_flatten`` and ``load_weights`` use, so a
6
+ torch model with the same attribute structure produces the same schema. ``apply`` hands
7
+ the payload to MLX's own safetensors reader and sets the arrays with ``Module.update``;
8
+ MLX optimizer state is keyed by path rather than by array identity, so it survives. bf16 and
9
+ f16 are native, so ``MlxValue`` converts with ``astype``; MLX flushes subnormals to zero on the
10
+ way, where NumPy and torch round them. E4M3 goes through ``mx.to_fp8`` and ``mx.from_fp8``,
11
+ which agree with torch's casts bit for bit; E2M1 nibbles and E8M0 scales are packed and
12
+ unpacked by hand, as in the torch backend, so both produce the same bytes.
13
+
14
+ Only ``mlx.core`` ships type information a checker accepts, so this module imports nothing
15
+ from ``mlx.nn`` and types the model structurally through ``Module``.
16
+ """
17
+
18
+ import io
19
+ import json
20
+ import math
21
+ from collections import Counter
22
+ from collections.abc import Iterable, Iterator, Mapping, Sequence
23
+ from dataclasses import dataclass, replace
24
+ from typing import Protocol, Self, assert_never, runtime_checkable
25
+
26
+ import mlx.core as mx
27
+ from mlx.utils import tree_unflatten
28
+
29
+ from gradientmail.adapters import Adapter, Backend, Value
30
+ from gradientmail.serialization import (
31
+ F4_BLOCK,
32
+ DType,
33
+ ParameterSpec,
34
+ Quantized,
35
+ Schema,
36
+ SchemaMismatch,
37
+ TensorBytes,
38
+ pack,
39
+ validate,
40
+ )
41
+
42
+ __all__ = ["MLX_DTYPE", "MlxAdapter", "MlxBackend", "MlxValue", "Module", "ParameterTree"]
43
+
44
+ type ParameterTree = mx.array | Mapping[str, ParameterTree] | Sequence[ParameterTree]
45
+
46
+ MLX_DTYPE: Mapping[DType, mx.Dtype] = {
47
+ "F32": mx.float32,
48
+ "F16": mx.float16,
49
+ "BF16": mx.bfloat16,
50
+ }
51
+
52
+
53
+ @runtime_checkable
54
+ class Module(Protocol):
55
+ """What the adapter needs from a model; every ``mlx.nn.Module`` satisfies it."""
56
+
57
+ def trainable_parameters(self) -> Mapping[str, ParameterTree]: ...
58
+
59
+ def update(self, parameters: dict[str, ParameterTree], strict: bool = True) -> object: ...
60
+
61
+
62
+ class MlxBackend(Backend):
63
+ def adapter(self, model: object) -> Adapter:
64
+ if not isinstance(model, Module):
65
+ raise TypeError(f"not an mlx.nn.Module: {type(model).__qualname__}")
66
+ return MlxAdapter(model)
67
+
68
+ def load(self, tensor: TensorBytes) -> Value:
69
+ match tensor.dtype:
70
+ case "F32" | "F16" | "BF16":
71
+ return MlxValue(_as_array(tensor).astype(mx.float32))
72
+ case other:
73
+ raise ValueError(f"{other} bytes need their scale: use dequantize")
74
+
75
+ def dequantize(self, data: TensorBytes, scale: TensorBytes, shape: tuple[int, ...]) -> Value:
76
+ match data.dtype:
77
+ case "F8_E4M3":
78
+ values = mx.from_fp8(_as_uint8(data.data), mx.float32) * _as_array(scale)
79
+ case "F4":
80
+ codes = _as_uint8(data.data)
81
+ nibbles = mx.stack([codes & 0xF, codes >> 4], axis=1).reshape(-1, F4_BLOCK)
82
+ magnitudes = mx.take(_F4_VALUES, nibbles & 7)
83
+ signed = mx.where((nibbles & 8) != 0, -magnitudes, magnitudes)
84
+ scales = _pow2(_as_uint8(scale.data).astype(mx.int32) - 127)
85
+ values = (signed * scales[:, None]).reshape(-1)[: math.prod(shape)]
86
+ case other:
87
+ raise ValueError(f"{other} is not a quantized dtype")
88
+ return MlxValue(values.reshape(shape))
89
+
90
+
91
+ class MlxAdapter(Adapter):
92
+ """Adapter over an MLX module.
93
+
94
+ ``schema`` rejects two paths that hold the same array object: MLX ties are
95
+ identity-only and break at the first ``update``, so weights must be tied
96
+ structurally (``embed.as_linear``), which lists them once.
97
+ """
98
+
99
+ def __init__(self, model: Module) -> None:
100
+ self._model = model
101
+
102
+ def schema(self) -> Schema[DType]:
103
+ parameters = self._parameters()
104
+ holders = Counter(id(array) for array in parameters.values())
105
+ tied = tuple(name for name, array in parameters.items() if holders[id(array)] > 1)
106
+ if tied:
107
+ raise SchemaMismatch(tied, "parameters tied by identity; tie them structurally")
108
+ unsupported = tuple(
109
+ name for name, array in parameters.items() if array.dtype not in _DTYPE_CODE
110
+ )
111
+ if unsupported:
112
+ raise SchemaMismatch(unsupported, "dtype is not float32, float16 or bfloat16")
113
+ return Schema(
114
+ tuple(
115
+ ParameterSpec(name, _DTYPE_CODE[parameters[name].dtype], parameters[name].shape)
116
+ for name in sorted(parameters)
117
+ )
118
+ )
119
+
120
+ def snapshot(self, names: Iterable[str]) -> Mapping[str, TensorBytes]:
121
+ parameters = self._parameters()
122
+ return {name: _tensor_bytes(parameters[name]) for name in names}
123
+
124
+ def apply(self, tensors: Mapping[str, TensorBytes]) -> None:
125
+ validate(self.schema(), tensors, tensors)
126
+ loaded = mx.load(io.BytesIO(pack(tensors)), format="safetensors")
127
+ assert isinstance(loaded, dict) # the stub's union; safetensors always decodes to a dict
128
+ self._model.update(tree_unflatten(list(loaded.items())))
129
+
130
+ def _parameters(self) -> Mapping[str, mx.array]:
131
+ def flattened(tree: ParameterTree, prefix: str) -> Iterator[tuple[str, mx.array]]:
132
+ match tree:
133
+ case mx.array():
134
+ yield prefix.removeprefix("."), tree
135
+ case Mapping():
136
+ for key, child in tree.items():
137
+ yield from flattened(child, f"{prefix}.{key}")
138
+ case Sequence():
139
+ for index, child in enumerate(tree):
140
+ yield from flattened(child, f"{prefix}.{index}")
141
+ case _:
142
+ assert_never(tree)
143
+
144
+ return dict(flattened(self._model.trainable_parameters(), ""))
145
+
146
+
147
+ @dataclass(frozen=True, eq=False)
148
+ class MlxValue(Value):
149
+ data: mx.array
150
+
151
+ def __add__(self, other: Self) -> Self:
152
+ return replace(self, data=self.data + other.data)
153
+
154
+ def __sub__(self, other: Self) -> Self:
155
+ return replace(self, data=self.data - other.data)
156
+
157
+ def __mul__(self, scalar: float) -> Self:
158
+ return replace(self, data=self.data * scalar)
159
+
160
+ def norm(self) -> float:
161
+ return float(mx.linalg.norm(mx.reshape(self.data, (-1,))))
162
+
163
+ def dot(self, other: Self) -> float:
164
+ return float(mx.sum(self.data * other.data))
165
+
166
+ def to_bytes(self, dtype: DType) -> TensorBytes:
167
+ return _tensor_bytes(self.data.astype(MLX_DTYPE[dtype]))
168
+
169
+ def quantize(self, dtype: Quantized) -> tuple[TensorBytes, TensorBytes]:
170
+ flat = mx.reshape(self.data, (-1,))
171
+ match dtype:
172
+ case "F8_E4M3":
173
+ largest = mx.max(mx.abs(flat)).reshape(1) if flat.size else mx.zeros((1,))
174
+ _, scale = _scales(largest, _E4M3_HEADROOM)
175
+ codes = mx.to_fp8(self.data / scale.reshape(()))
176
+ return (
177
+ TensorBytes(dtype, self.data.shape, _bytes(codes)),
178
+ TensorBytes("F32", (), _bytes(scale.reshape(()))),
179
+ )
180
+ case "F4":
181
+ blocks = -(-flat.size // F4_BLOCK)
182
+ blocked = mx.pad(flat, (0, blocks * F4_BLOCK - flat.size)).reshape(blocks, F4_BLOCK)
183
+ exponents, scales = _scales(mx.max(mx.abs(blocked), axis=1), _E2M1_HEADROOM)
184
+ q = mx.minimum(mx.abs(blocked) / scales[:, None], 6.0)
185
+ # Round to nearest even on the E2M1 grid 0, .5, 1, 1.5, 2, 3, 4, 6, whose codes
186
+ # are 0 to 7 in that order.
187
+ codes = mx.where(
188
+ q < 2, mx.round(q * 2), mx.where(q < 4, mx.round(q) + 2, mx.round(q / 2) + 4)
189
+ ).astype(mx.uint8)
190
+ codes = codes | mx.where(blocked < 0, 8, 0).astype(mx.uint8)
191
+ pairs = codes.reshape(-1, 2)
192
+ packed = pairs[:, 0] | (pairs[:, 1] << 4)
193
+ return (
194
+ TensorBytes(dtype, (blocks * F4_BLOCK,), _bytes(packed)),
195
+ TensorBytes("F8_E8M0", (blocks,), _bytes((exponents + 127).astype(mx.uint8))),
196
+ )
197
+ case _:
198
+ raise ValueError(f"{dtype} is not a quantized dtype")
199
+
200
+
201
+ _DTYPE_CODE: Mapping[mx.Dtype, DType] = {dtype: code for code, dtype in MLX_DTYPE.items()}
202
+ _F4_VALUES = mx.array([0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0])
203
+ # Scales put the largest magnitude of a tensor or block in the binade just under the format's
204
+ # top: below 2^8 for E4M3 (whose maximum is 448), in [4, 8) for E2M1 (whose maximum is 6).
205
+ _E4M3_HEADROOM = 7
206
+ _E2M1_HEADROOM = 2
207
+
208
+
209
+ def _scales(largest: mx.array, headroom: int) -> tuple[mx.array, mx.array]:
210
+ """Per entry of ``largest``: the exponent ``k`` with ``largest < 2^(k + headroom + 1)``
211
+ (zero gets ``k = -headroom``, a scale of one), clamped to what E8M0 holds, and ``2^k``."""
212
+ exponents = (
213
+ mx.where(largest > 0, (largest.view(mx.uint32) >> 23).astype(mx.int32) - 127, headroom)
214
+ - headroom
215
+ )
216
+ exponents = mx.clip(exponents, -126, 127)
217
+ return exponents, _pow2(exponents)
218
+
219
+
220
+ def _pow2(exponents: mx.array) -> mx.array:
221
+ """``2^k`` for int32 ``k`` in ``[-126, 127]``, built from the fp32 exponent bits."""
222
+ return ((exponents + 127).astype(mx.uint32) << 23).view(mx.float32)
223
+
224
+
225
+ def _as_array(t: TensorBytes) -> mx.array:
226
+ # The only typed way from bytes to an array is MLX's own safetensors reader.
227
+ return _loaded(pack({"tensor": t}))
228
+
229
+
230
+ def _as_uint8(data: bytes) -> mx.array:
231
+ """``data`` as a flat uint8 array, through a safetensors blob written here: the reader is the
232
+ typed way in, and ``U8`` is not a payload dtype ``pack`` knows."""
233
+ header = json.dumps(
234
+ {"tensor": {"dtype": "U8", "shape": [len(data)], "data_offsets": [0, len(data)]}}
235
+ ).encode()
236
+ return _loaded(len(header).to_bytes(8, "little") + header + data)
237
+
238
+
239
+ def _loaded(blob: bytes) -> mx.array:
240
+ loaded = mx.load(io.BytesIO(blob), format="safetensors")
241
+ assert isinstance(loaded, dict) # the stub's union; safetensors always decodes to a dict
242
+ return loaded["tensor"]
243
+
244
+
245
+ def _bytes(array: mx.array) -> bytes:
246
+ # Flattening first makes a 0-d array viewable as uint8 and gives a transposed array its
247
+ # logical row-major bytes; the buffer protocol evaluates the array.
248
+ return memoryview(mx.reshape(array, (-1,)).view(mx.uint8)).tobytes()
249
+
250
+
251
+ def _tensor_bytes(array: mx.array) -> TensorBytes:
252
+ return TensorBytes(_DTYPE_CODE[array.dtype], array.shape, _bytes(array))
@@ -0,0 +1,205 @@
1
+ """PyTorch backend: ``nn.Module`` parameters in and out as bytes, and the outer step on tensors.
2
+
3
+ ``TorchAdapter.snapshot`` reads host memory through ``ctypes`` because ``memoryview(tensor)``
4
+ is not supported. ``apply`` writes with ``Tensor.copy_`` under ``no_grad``: it moves the data
5
+ to the parameter's device, writes through shared storage (tied weights) and never rebinds the
6
+ ``nn.Parameter``, so inner-optimizer state keyed by it survives. bf16, f16 and E4M3 are
7
+ native, so ``TorchValue`` converts with ``Tensor.to``; E2M1 nibbles and E8M0 scales are
8
+ packed and unpacked by hand.
9
+ """
10
+
11
+ import ctypes
12
+ import math
13
+ from collections.abc import Iterable, Mapping
14
+ from dataclasses import dataclass, replace
15
+ from typing import Self
16
+
17
+ import torch
18
+ from torch import nn
19
+
20
+ from gradientmail.adapters import Adapter, Backend, Value
21
+ from gradientmail.serialization import (
22
+ F4_BLOCK,
23
+ DType,
24
+ ParameterSpec,
25
+ Quantized,
26
+ Schema,
27
+ SchemaMismatch,
28
+ TensorBytes,
29
+ validate,
30
+ )
31
+
32
+ __all__ = ["TORCH_DTYPE", "TorchAdapter", "TorchBackend", "TorchValue"]
33
+
34
+ TORCH_DTYPE: Mapping[DType, torch.dtype] = {
35
+ "F32": torch.float32,
36
+ "F16": torch.float16,
37
+ "BF16": torch.bfloat16,
38
+ }
39
+
40
+
41
+ class TorchBackend(Backend):
42
+ """Adapts ``nn.Module``s; loads values on ``device`` (``"cpu"`` unless told otherwise)."""
43
+
44
+ def __init__(self, device: str = "cpu") -> None:
45
+ self._device = device
46
+
47
+ def adapter(self, model: object) -> Adapter:
48
+ if not isinstance(model, nn.Module):
49
+ raise TypeError(f"not a torch.nn.Module: {type(model).__qualname__}")
50
+ return TorchAdapter(model)
51
+
52
+ def load(self, tensor: TensorBytes) -> Value:
53
+ return TorchValue(_as_tensor(tensor).to(self._device, torch.float32))
54
+
55
+ def dequantize(self, data: TensorBytes, scale: TensorBytes, shape: tuple[int, ...]) -> Value:
56
+ match data.dtype:
57
+ case "F8_E4M3":
58
+ values = _raw(data).view(torch.float8_e4m3fn).to(torch.float32) * _as_tensor(scale)
59
+ case "F4":
60
+ codes = _raw(data)
61
+ nibbles = torch.stack((codes & 0xF, codes >> 4), dim=1).reshape(-1, F4_BLOCK)
62
+ magnitudes = _F4_VALUES[(nibbles & 7).long()]
63
+ signed = torch.where((nibbles & 8) != 0, -magnitudes, magnitudes)
64
+ scales = _pow2(_raw(scale).to(torch.int32) - 127)
65
+ values = (signed * scales[:, None]).reshape(-1)[: math.prod(shape)]
66
+ case other:
67
+ raise ValueError(f"{other} is not a quantized dtype")
68
+ return TorchValue(values.reshape(shape).to(self._device))
69
+
70
+
71
+ class TorchAdapter(Adapter):
72
+ """Adapter over ``model.named_parameters()``, where a tied parameter appears once."""
73
+
74
+ def __init__(self, model: nn.Module) -> None:
75
+ self._model = model
76
+
77
+ def schema(self) -> Schema[DType]:
78
+ def spec(name: str) -> ParameterSpec[DType]:
79
+ p = parameters[name]
80
+ return ParameterSpec(name, _DTYPE_CODE[p.dtype], tuple(p.shape))
81
+
82
+ parameters = dict(self._model.named_parameters())
83
+ unsupported = tuple(name for name, p in parameters.items() if p.dtype not in _DTYPE_CODE)
84
+ if unsupported:
85
+ raise SchemaMismatch(unsupported, "parameter dtype is not float32, float16 or bfloat16")
86
+ return Schema(tuple(spec(name) for name in sorted(parameters)))
87
+
88
+ def snapshot(self, names: Iterable[str]) -> Mapping[str, TensorBytes]:
89
+ parameters = dict(self._model.named_parameters())
90
+ return {name: _tensor_bytes(parameters[name].detach()) for name in names}
91
+
92
+ def apply(self, tensors: Mapping[str, TensorBytes]) -> None:
93
+ # copy_ would cast a wrong dtype and broadcast a compatible wrong shape silently.
94
+ validate(self.schema(), tensors, tensors)
95
+ parameters = dict(self._model.named_parameters())
96
+ with torch.no_grad():
97
+ for name, t in tensors.items():
98
+ parameters[name].copy_(_as_tensor(t))
99
+
100
+
101
+ @dataclass(frozen=True, eq=False)
102
+ class TorchValue(Value):
103
+ data: torch.Tensor
104
+
105
+ def __add__(self, other: Self) -> Self:
106
+ return replace(self, data=self.data + other.data)
107
+
108
+ def __sub__(self, other: Self) -> Self:
109
+ return replace(self, data=self.data - other.data)
110
+
111
+ def __mul__(self, scalar: float) -> Self:
112
+ return replace(self, data=self.data * scalar)
113
+
114
+ def norm(self) -> float:
115
+ return float(self.data.square().sum().sqrt())
116
+
117
+ def dot(self, other: Self) -> float:
118
+ return float((self.data * other.data).sum())
119
+
120
+ def to_bytes(self, dtype: DType) -> TensorBytes:
121
+ return _tensor_bytes(self.data.to(TORCH_DTYPE[dtype]))
122
+
123
+ def quantize(self, dtype: Quantized) -> tuple[TensorBytes, TensorBytes]:
124
+ flat = self.data.reshape(-1)
125
+ match dtype:
126
+ case "F8_E4M3":
127
+ largest = flat.abs().max().reshape(1) if flat.numel() else torch.zeros(1)
128
+ _, scale = _scales(largest, _E4M3_HEADROOM)
129
+ codes = (self.data / scale.reshape(())).to(torch.float8_e4m3fn)
130
+ return (
131
+ TensorBytes(dtype, tuple(self.data.shape), _bytes(codes)),
132
+ TensorBytes("F32", (), _bytes(scale.reshape(()))),
133
+ )
134
+ case "F4":
135
+ blocks = -(-flat.numel() // F4_BLOCK)
136
+ padding = torch.zeros(blocks * F4_BLOCK - flat.numel(), dtype=flat.dtype)
137
+ blocked = torch.cat((flat, padding)).reshape(blocks, F4_BLOCK)
138
+ exponents, scales = _scales(blocked.abs().amax(dim=1), _E2M1_HEADROOM)
139
+ q = (blocked.abs() / scales[:, None]).clamp(max=6.0)
140
+ # Round to nearest even on the E2M1 grid 0, .5, 1, 1.5, 2, 3, 4, 6, whose codes
141
+ # are 0 to 7 in that order.
142
+ codes = torch.where(
143
+ q < 2,
144
+ torch.round(q * 2),
145
+ torch.where(q < 4, torch.round(q) + 2, torch.round(q / 2) + 4),
146
+ ).to(torch.uint8)
147
+ codes = codes | torch.where(blocked < 0, 8, 0).to(torch.uint8)
148
+ pairs = codes.reshape(-1, 2)
149
+ packed = pairs[:, 0] | (pairs[:, 1] << 4)
150
+ return (
151
+ TensorBytes(dtype, (blocks * F4_BLOCK,), _bytes(packed)),
152
+ TensorBytes("F8_E8M0", (blocks,), _bytes((exponents + 127).to(torch.uint8))),
153
+ )
154
+ case _:
155
+ raise ValueError(f"{dtype} is not a quantized dtype")
156
+
157
+
158
+ _DTYPE_CODE: Mapping[torch.dtype, DType] = {dtype: code for code, dtype in TORCH_DTYPE.items()}
159
+ _F4_VALUES = torch.tensor([0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0])
160
+ # Scales put the largest magnitude of a tensor or block in the binade just under the format's
161
+ # top: below 2^8 for E4M3 (whose maximum is 448), in [4, 8) for E2M1 (whose maximum is 6).
162
+ _E4M3_HEADROOM = 7
163
+ _E2M1_HEADROOM = 2
164
+
165
+
166
+ def _scales(largest: torch.Tensor, headroom: int) -> tuple[torch.Tensor, torch.Tensor]:
167
+ """Per entry of ``largest``: the exponent ``k`` with ``largest < 2^(k + headroom + 1)``
168
+ (zero gets ``k = -headroom``, a scale of one), clamped to what E8M0 holds, and ``2^k``."""
169
+ exponents = (
170
+ torch.where(largest > 0, (largest.view(torch.int32) >> 23) - 127, headroom) - headroom
171
+ )
172
+ exponents = exponents.clamp(-126, 127)
173
+ return exponents, _pow2(exponents)
174
+
175
+
176
+ def _pow2(exponents: torch.Tensor) -> torch.Tensor:
177
+ """``2^k`` for int32 ``k`` in ``[-126, 127]``, built from the fp32 exponent bits."""
178
+ return ((exponents + 127) << 23).view(torch.float32)
179
+
180
+
181
+ def _as_tensor(t: TensorBytes) -> torch.Tensor:
182
+ match t.dtype:
183
+ case "F32" | "F16" | "BF16" as dtype:
184
+ # frombuffer rejects an empty buffer and warns on a read-only one.
185
+ if not t.data:
186
+ return torch.empty(t.shape, dtype=TORCH_DTYPE[dtype])
187
+ return torch.frombuffer(bytearray(t.data), dtype=TORCH_DTYPE[dtype]).reshape(t.shape)
188
+ case other:
189
+ raise ValueError(f"{other} bytes need their scale: use dequantize")
190
+
191
+
192
+ def _raw(t: TensorBytes) -> torch.Tensor:
193
+ """``t``'s bytes as a flat uint8 tensor."""
194
+ if not t.data:
195
+ return torch.empty(0, dtype=torch.uint8)
196
+ return torch.frombuffer(bytearray(t.data), dtype=torch.uint8)
197
+
198
+
199
+ def _bytes(tensor: torch.Tensor) -> bytes:
200
+ t = tensor.to("cpu").contiguous()
201
+ return ctypes.string_at(t.data_ptr(), t.numel() * t.element_size())
202
+
203
+
204
+ def _tensor_bytes(tensor: torch.Tensor) -> TensorBytes:
205
+ return TensorBytes(_DTYPE_CODE[tensor.dtype], tuple(tensor.shape), _bytes(tensor))