gradientmail 0.0.1__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gabriel Francisco
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,142 @@
1
+ Metadata-Version: 2.4
2
+ Name: gradientmail
3
+ Version: 0.0.1
4
+ Summary: Distributed training through asynchronous message exchange
5
+ Author: Gabriel Francisco
6
+ Author-email: Gabriel Francisco <gabfssilva@gmail.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Requires-Dist: safetensors>=0.8,<1
10
+ Requires-Dist: jax ; extra == 'jax'
11
+ Requires-Dist: keras ; extra == 'keras'
12
+ Requires-Dist: mlx ; extra == 'mlx'
13
+ Requires-Dist: boto3>=1.36 ; extra == 's3'
14
+ Requires-Dist: tensorflow ; extra == 'tensorflow'
15
+ Requires-Dist: torch ; extra == 'torch'
16
+ Requires-Dist: zstandard>=0.23 ; extra == 'zstd'
17
+ Requires-Python: >=3.12
18
+ Project-URL: Homepage, https://ppgsi-lab.github.io/gradientmail/
19
+ Project-URL: Documentation, https://ppgsi-lab.github.io/gradientmail/docs/guide/
20
+ Project-URL: Repository, https://github.com/ppgsi-lab/gradientmail
21
+ Provides-Extra: jax
22
+ Provides-Extra: keras
23
+ Provides-Extra: mlx
24
+ Provides-Extra: s3
25
+ Provides-Extra: tensorflow
26
+ Provides-Extra: torch
27
+ Provides-Extra: zstd
28
+ Description-Content-Type: text/markdown
29
+
30
+ # GradientMail
31
+
32
+ Distributed training with [Decoupled DiLoCo](https://arxiv.org/abs/2604.21428) over a shared mailbox. Nodes exchange parameter fragments through S3, R2, MinIO or Hugging Face Storage Buckets, without P2P connections or a separate coordinator.
33
+
34
+ The cluster is heterogeneous and elastic: nodes with different GPUs and frameworks (PyTorch or MLX) can join an ongoing run, contribute to training for a period and leave. One of the training nodes merges each round and publishes the result. If it fails, another takes over.
35
+
36
+ ## How it works
37
+
38
+ Each learner keeps a local copy of the model and runs its own optimizer. Parameters are split into fragments, synchronized on a staggered schedule. For each fragment, a learner publishes its update: the difference between the committed parameters it last aligned with and the ones it trained.
39
+
40
+ ![Three nodes and a mailbox. Node A (MLX on Apple silicon), node B (PyTorch on an H100) and node C (PyTorch on an RTX 4090) each run a learner and a syncer. Each learner trains fragment k, puts its delta, the anchor minus its parameters, in the mailbox, polls for commits and aligns with them between its steps. Node B's syncer is elected for the round: it lists and gets the round's deltas, merges them with RDA, applies the Nesterov outer step and commits fragment k with the outer state. The other syncers follow it and take over if node B stalls. The mailbox, an S3, R2, MinIO or Hugging Face bucket, holds the deltas and the commits; every arrow is a request to it.](https://ppgsi-lab.github.io/gradientmail/docs/assets/training-flow.svg)
41
+
42
+ An elected learner reads the updates for a round, merges them and applies the outer optimizer. It commits the updated fragment through the mailbox; learners poll for new commits and align their local models with them. All training and merge arithmetic runs on the learners.
43
+
44
+ ## Install
45
+
46
+ Python 3.12+.
47
+
48
+ ```sh
49
+ uv add "gradientmail[s3,torch]"
50
+ # or
51
+ pip install "gradientmail[s3,torch]"
52
+ ```
53
+
54
+ Use `mlx` instead of `torch` for MLX on Apple Silicon.
55
+
56
+ ## Usage
57
+
58
+ Add a `Session` to your existing training loop:
59
+
60
+ ```python
61
+ import gradientmail as gm
62
+
63
+ with gm.Session(
64
+ model,
65
+ algorithm=gm.DiLoCo(sync_every=100, fragments=8),
66
+ mailbox=gm.S3Mailbox(bucket="my-training", prefix="experiments/run-1"),
67
+ ) as session:
68
+ for batch in dataloader:
69
+ loss = train_step(model, optimizer, batch)
70
+ session.step(samples=len(batch))
71
+ ```
72
+
73
+ Run this on each node with the same bucket, prefix, algorithm configuration and parameter schema (names, shapes and dtypes). `model` can be a PyTorch or MLX module. The first node initializes the run; later nodes load its committed parameters before training. Use a new prefix for each run.
74
+
75
+ `sync_every=100, fragments=8` splits the model into up to eight parameter groups, each synchronized every 100 local steps. `step(samples=...)` reports the tokens or samples processed by that optimizer step. Leaving the context waits for the node's rounds in flight to commit, stopping the wait if the run stalls, and sets the model to the newest committed parameters. Steps trained since a fragment's last round are dropped, as in the DiLoCo papers; `flush_on_close=True` publishes them instead, and a round made only of those final contributions commits them without the outer momentum.
76
+
77
+ Training continues during synchronization. Aligning a fragment with its commit replaces local updates made while it was in flight. `overlap_steps=N` on `gm.DiLoCo` bounds that: a node waits once a round it published N steps ago is still not aligned with, and `0` is standard DiLoCo's barrier. `mixing=0.5` keeps that share of the local parameters when aligning, blending them with the commit instead of replacing them, which Streaming DiLoCo found to recover most of what overlapped steps cost.
78
+
79
+ The defaults follow Decoupled DiLoCo. `fragments=1, in_flight=1, overlap_steps=0, merge=gm.WeightedMean()` with `quorum_size` equal to the number of nodes is standard DiLoCo. `partition=gm.Strided()` deals the transformer blocks round the fragments, Streaming DiLoCo's pattern, instead of balancing the fragments by bytes.
80
+
81
+ `quorum_size` sets the target number of contributions per round (default 1). Rounds can close with fewer contributions once every live node has reported. Crashed nodes stop being counted after their lease expires (default 120 seconds).
82
+
83
+ `global_batch_size` fixes the batch the live nodes share. `session.batch_size` is this node's share, the global batch divided by the live nodes and rounded up, for the training loop to size its batches by; it changes as nodes join and leave. Without it, each node's batch is its own and the global batch grows with the node count.
84
+
85
+ `delta_dtype="BF16"` sends each update in bf16, halving an fp32 model's uploads at a rounding of 8 significant bits per element; `"F8_E4M3"` quarters them and `"F4"` takes an eighth, each element scaled by a power of two per tensor or per block of 32, as Streaming DiLoCo sends its outer gradients; by default it keeps each parameter's own dtype. `commit_dtype` publishes each commit's change in the same formats, so a node holding the previous version downloads that instead of the full parameters. `compression="zstd"` on `Session` stores this node's payloads byte-shuffled and zstandard-compressed (the `zstd` extra), about 16% smaller for fp32 and 31% for bf16; every node reads either format, so nodes of one run may differ.
86
+
87
+ `S3Mailbox` uses boto3 credentials. For other S3-compatible services, pass `endpoint_url=`; Hugging Face Storage Buckets also need `request_checksums="when_required"`.
88
+
89
+ For evaluation, `gm.align(model, mailbox=...)` loads the committed model without joining the run. GradientMail does not save your inner optimizer state; checkpoint it separately.
90
+
91
+ ## Examples
92
+
93
+ Run two synthetic learners locally, with PyTorch or MLX installed:
94
+
95
+ ```sh
96
+ uv run python examples/synthetic.py
97
+ ```
98
+
99
+ For a PyTorch/MLX run across two machines, use [torch_learner.py](https://github.com/ppgsi-lab/gradientmail/blob/main/examples/torch_learner.py) and [mlx_learner.py](https://github.com/ppgsi-lab/gradientmail/blob/main/examples/mlx_learner.py). The PyTorch example includes the MinIO setup and commands.
100
+
101
+ ## Further reading
102
+
103
+ - [Guide](https://ppgsi-lab.github.io/gradientmail/docs/guide/)
104
+
105
+ A collection of explanations for understanding distributed training.
106
+
107
+ - [Shared Queue](https://ppgsi-lab.github.io/gradientmail/docs/patterns/00-shared-queue.html)
108
+
109
+ Distributing work across nodes with different processing speeds.
110
+
111
+ - [Barrier](https://ppgsi-lab.github.io/gradientmail/docs/patterns/01-barrier.html)
112
+
113
+ Holding nodes at a named point until a number of them have reached it.
114
+
115
+ - [Posts](https://ppgsi-lab.github.io/gradientmail/docs/posts/)
116
+
117
+ A Pythia-14M run on four rented GPUs, MLX and PyTorch learners in one run, merging deltas with RDA, and implementing a mailbox backend.
118
+
119
+ ## Development
120
+
121
+ From a checkout:
122
+
123
+ ```sh
124
+ uv sync --extra s3 --extra torch
125
+ uv run pytest
126
+ uv run ruff check
127
+ uv run ruff format --check
128
+ uv run pyrefly check
129
+ ```
130
+
131
+ Tests use memory and moto stores by default. Set `GRADIENTMAIL_S3_ENDPOINT` to test against an S3-compatible store. Framework tests skip when their dependencies are absent.
132
+
133
+ ## Citation
134
+
135
+ ```bibtex
136
+ @software{gradientmail,
137
+ author = {dos Santos Silva, Gabriel Francisco},
138
+ title = {GradientMail: Distributed training through asynchronous message exchange},
139
+ year = {2026},
140
+ url = {https://github.com/ppgsi-lab/gradientmail},
141
+ }
142
+ ```
@@ -0,0 +1,113 @@
1
+ # GradientMail
2
+
3
+ Distributed training with [Decoupled DiLoCo](https://arxiv.org/abs/2604.21428) over a shared mailbox. Nodes exchange parameter fragments through S3, R2, MinIO or Hugging Face Storage Buckets, without P2P connections or a separate coordinator.
4
+
5
+ The cluster is heterogeneous and elastic: nodes with different GPUs and frameworks (PyTorch or MLX) can join an ongoing run, contribute to training for a period and leave. One of the training nodes merges each round and publishes the result. If it fails, another takes over.
6
+
7
+ ## How it works
8
+
9
+ Each learner keeps a local copy of the model and runs its own optimizer. Parameters are split into fragments, synchronized on a staggered schedule. For each fragment, a learner publishes its update: the difference between the committed parameters it last aligned with and the ones it trained.
10
+
11
+ ![Three nodes and a mailbox. Node A (MLX on Apple silicon), node B (PyTorch on an H100) and node C (PyTorch on an RTX 4090) each run a learner and a syncer. Each learner trains fragment k, puts its delta, the anchor minus its parameters, in the mailbox, polls for commits and aligns with them between its steps. Node B's syncer is elected for the round: it lists and gets the round's deltas, merges them with RDA, applies the Nesterov outer step and commits fragment k with the outer state. The other syncers follow it and take over if node B stalls. The mailbox, an S3, R2, MinIO or Hugging Face bucket, holds the deltas and the commits; every arrow is a request to it.](https://ppgsi-lab.github.io/gradientmail/docs/assets/training-flow.svg)
12
+
13
+ An elected learner reads the updates for a round, merges them and applies the outer optimizer. It commits the updated fragment through the mailbox; learners poll for new commits and align their local models with them. All training and merge arithmetic runs on the learners.
14
+
15
+ ## Install
16
+
17
+ Python 3.12+.
18
+
19
+ ```sh
20
+ uv add "gradientmail[s3,torch]"
21
+ # or
22
+ pip install "gradientmail[s3,torch]"
23
+ ```
24
+
25
+ Use `mlx` instead of `torch` for MLX on Apple Silicon.
26
+
27
+ ## Usage
28
+
29
+ Add a `Session` to your existing training loop:
30
+
31
+ ```python
32
+ import gradientmail as gm
33
+
34
+ with gm.Session(
35
+ model,
36
+ algorithm=gm.DiLoCo(sync_every=100, fragments=8),
37
+ mailbox=gm.S3Mailbox(bucket="my-training", prefix="experiments/run-1"),
38
+ ) as session:
39
+ for batch in dataloader:
40
+ loss = train_step(model, optimizer, batch)
41
+ session.step(samples=len(batch))
42
+ ```
43
+
44
+ Run this on each node with the same bucket, prefix, algorithm configuration and parameter schema (names, shapes and dtypes). `model` can be a PyTorch or MLX module. The first node initializes the run; later nodes load its committed parameters before training. Use a new prefix for each run.
45
+
46
+ `sync_every=100, fragments=8` splits the model into up to eight parameter groups, each synchronized every 100 local steps. `step(samples=...)` reports the tokens or samples processed by that optimizer step. Leaving the context waits for the node's rounds in flight to commit, stopping the wait if the run stalls, and sets the model to the newest committed parameters. Steps trained since a fragment's last round are dropped, as in the DiLoCo papers; `flush_on_close=True` publishes them instead, and a round made only of those final contributions commits them without the outer momentum.
47
+
48
+ Training continues during synchronization. Aligning a fragment with its commit replaces local updates made while it was in flight. `overlap_steps=N` on `gm.DiLoCo` bounds that: a node waits once a round it published N steps ago is still not aligned with, and `0` is standard DiLoCo's barrier. `mixing=0.5` keeps that share of the local parameters when aligning, blending them with the commit instead of replacing them, which Streaming DiLoCo found to recover most of what overlapped steps cost.
49
+
50
+ The defaults follow Decoupled DiLoCo. `fragments=1, in_flight=1, overlap_steps=0, merge=gm.WeightedMean()` with `quorum_size` equal to the number of nodes is standard DiLoCo. `partition=gm.Strided()` deals the transformer blocks round the fragments, Streaming DiLoCo's pattern, instead of balancing the fragments by bytes.
51
+
52
+ `quorum_size` sets the target number of contributions per round (default 1). Rounds can close with fewer contributions once every live node has reported. Crashed nodes stop being counted after their lease expires (default 120 seconds).
53
+
54
+ `global_batch_size` fixes the batch the live nodes share. `session.batch_size` is this node's share, the global batch divided by the live nodes and rounded up, for the training loop to size its batches by; it changes as nodes join and leave. Without it, each node's batch is its own and the global batch grows with the node count.
55
+
56
+ `delta_dtype="BF16"` sends each update in bf16, halving an fp32 model's uploads at a rounding of 8 significant bits per element; `"F8_E4M3"` quarters them and `"F4"` takes an eighth, each element scaled by a power of two per tensor or per block of 32, as Streaming DiLoCo sends its outer gradients; by default it keeps each parameter's own dtype. `commit_dtype` publishes each commit's change in the same formats, so a node holding the previous version downloads that instead of the full parameters. `compression="zstd"` on `Session` stores this node's payloads byte-shuffled and zstandard-compressed (the `zstd` extra), about 16% smaller for fp32 and 31% for bf16; every node reads either format, so nodes of one run may differ.
57
+
58
+ `S3Mailbox` uses boto3 credentials. For other S3-compatible services, pass `endpoint_url=`; Hugging Face Storage Buckets also need `request_checksums="when_required"`.
59
+
60
+ For evaluation, `gm.align(model, mailbox=...)` loads the committed model without joining the run. GradientMail does not save your inner optimizer state; checkpoint it separately.
61
+
62
+ ## Examples
63
+
64
+ Run two synthetic learners locally, with PyTorch or MLX installed:
65
+
66
+ ```sh
67
+ uv run python examples/synthetic.py
68
+ ```
69
+
70
+ For a PyTorch/MLX run across two machines, use [torch_learner.py](https://github.com/ppgsi-lab/gradientmail/blob/main/examples/torch_learner.py) and [mlx_learner.py](https://github.com/ppgsi-lab/gradientmail/blob/main/examples/mlx_learner.py). The PyTorch example includes the MinIO setup and commands.
71
+
72
+ ## Further reading
73
+
74
+ - [Guide](https://ppgsi-lab.github.io/gradientmail/docs/guide/)
75
+
76
+ A collection of explanations for understanding distributed training.
77
+
78
+ - [Shared Queue](https://ppgsi-lab.github.io/gradientmail/docs/patterns/00-shared-queue.html)
79
+
80
+ Distributing work across nodes with different processing speeds.
81
+
82
+ - [Barrier](https://ppgsi-lab.github.io/gradientmail/docs/patterns/01-barrier.html)
83
+
84
+ Holding nodes at a named point until a number of them have reached it.
85
+
86
+ - [Posts](https://ppgsi-lab.github.io/gradientmail/docs/posts/)
87
+
88
+ A Pythia-14M run on four rented GPUs, MLX and PyTorch learners in one run, merging deltas with RDA, and implementing a mailbox backend.
89
+
90
+ ## Development
91
+
92
+ From a checkout:
93
+
94
+ ```sh
95
+ uv sync --extra s3 --extra torch
96
+ uv run pytest
97
+ uv run ruff check
98
+ uv run ruff format --check
99
+ uv run pyrefly check
100
+ ```
101
+
102
+ Tests use memory and moto stores by default. Set `GRADIENTMAIL_S3_ENDPOINT` to test against an S3-compatible store. Framework tests skip when their dependencies are absent.
103
+
104
+ ## Citation
105
+
106
+ ```bibtex
107
+ @software{gradientmail,
108
+ author = {dos Santos Silva, Gabriel Francisco},
109
+ title = {GradientMail: Distributed training through asynchronous message exchange},
110
+ year = {2026},
111
+ url = {https://github.com/ppgsi-lab/gradientmail},
112
+ }
113
+ ```
@@ -0,0 +1,72 @@
1
+ [project]
2
+ name = "gradientmail"
3
+ version = "0.0.1"
4
+ description = "Distributed training through asynchronous message exchange"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.12"
9
+ dependencies = ["safetensors>=0.8,<1"]
10
+
11
+ [[project.authors]]
12
+ name = "Gabriel Francisco"
13
+ email = "gabfssilva@gmail.com"
14
+
15
+ [project.urls]
16
+ Homepage = "https://ppgsi-lab.github.io/gradientmail/"
17
+ Documentation = "https://ppgsi-lab.github.io/gradientmail/docs/guide/"
18
+ Repository = "https://github.com/ppgsi-lab/gradientmail"
19
+
20
+ [project.optional-dependencies]
21
+ s3 = ["boto3>=1.36"]
22
+ torch = ["torch"]
23
+ mlx = ["mlx"]
24
+ jax = ["jax"]
25
+ keras = ["keras"]
26
+ tensorflow = ["tensorflow"]
27
+ zstd = ["zstandard>=0.23"]
28
+
29
+ [dependency-groups]
30
+ dev = [
31
+ "pytest>=9",
32
+ "pytest-describe>=3",
33
+ "hypothesis",
34
+ "ruff",
35
+ "pyrefly>=1.3",
36
+ "moto[s3]>=5.1.5",
37
+ "boto3>=1.36",
38
+ "boto3-stubs-lite[s3]>=1.36",
39
+ "zstandard>=0.23",
40
+ ]
41
+
42
+ [build-system]
43
+ requires = ["uv_build>=0.13.0,<0.14.0"]
44
+ build-backend = "uv_build"
45
+
46
+ [tool.pytest.ini_options]
47
+ testpaths = ["tests"]
48
+ describe_prefixes = [
49
+ "describe",
50
+ "when",
51
+ ]
52
+
53
+ [tool.ruff]
54
+ target-version = "py312"
55
+ line-length = 100
56
+ extend-exclude = ["*.md"]
57
+
58
+ [tool.ruff.lint]
59
+ select = [
60
+ "E",
61
+ "F",
62
+ "I",
63
+ "UP",
64
+ "B",
65
+ "SIM",
66
+ "RUF",
67
+ ]
68
+
69
+ [tool.pyrefly]
70
+ project-includes = ["src"]
71
+ python-version = "3.12"
72
+ search-path = ["src"]
@@ -0,0 +1,62 @@
1
+ [project]
2
+ name = "gradientmail"
3
+ version = "0.0.1"
4
+ description = "Distributed training through asynchronous message exchange"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [
9
+ { name = "Gabriel Francisco", email = "gabfssilva@gmail.com" }
10
+ ]
11
+ requires-python = ">=3.12"
12
+ dependencies = [
13
+ "safetensors>=0.8,<1",
14
+ ]
15
+
16
+ [project.urls]
17
+ Homepage = "https://ppgsi-lab.github.io/gradientmail/"
18
+ Documentation = "https://ppgsi-lab.github.io/gradientmail/docs/guide/"
19
+ Repository = "https://github.com/ppgsi-lab/gradientmail"
20
+
21
+ [project.optional-dependencies]
22
+ s3 = ["boto3>=1.36"]
23
+ torch = ["torch"]
24
+ mlx = ["mlx"]
25
+ jax = ["jax"]
26
+ keras = ["keras"]
27
+ tensorflow = ["tensorflow"]
28
+ zstd = ["zstandard>=0.23"]
29
+
30
+ [dependency-groups]
31
+ dev = [
32
+ "pytest>=9",
33
+ "pytest-describe>=3",
34
+ "hypothesis",
35
+ "ruff",
36
+ "pyrefly>=1.3",
37
+ "moto[s3]>=5.1.5",
38
+ "boto3>=1.36",
39
+ "boto3-stubs-lite[s3]>=1.36",
40
+ "zstandard>=0.23",
41
+ ]
42
+
43
+ [build-system]
44
+ requires = ["uv_build>=0.13.0,<0.14.0"]
45
+ build-backend = "uv_build"
46
+
47
+ [tool.pytest.ini_options]
48
+ testpaths = ["tests"]
49
+ describe_prefixes = ["describe", "when"]
50
+
51
+ [tool.ruff]
52
+ target-version = "py312"
53
+ line-length = 100
54
+ extend-exclude = ["*.md"]
55
+
56
+ [tool.ruff.lint]
57
+ select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
58
+
59
+ [tool.pyrefly]
60
+ project-includes = ["src"]
61
+ python-version = "3.12"
62
+ search-path = ["src"]
@@ -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")