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
gradientmail/__init__.py
ADDED
|
@@ -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))
|