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.
- gradientmail-0.0.1/LICENSE +21 -0
- gradientmail-0.0.1/PKG-INFO +142 -0
- gradientmail-0.0.1/README.md +113 -0
- gradientmail-0.0.1/pyproject.toml +72 -0
- gradientmail-0.0.1/pyproject.toml.orig +62 -0
- gradientmail-0.0.1/src/gradientmail/__init__.py +59 -0
- gradientmail-0.0.1/src/gradientmail/adapters/__init__.py +174 -0
- gradientmail-0.0.1/src/gradientmail/adapters/mlx.py +252 -0
- gradientmail-0.0.1/src/gradientmail/adapters/torch.py +205 -0
- gradientmail-0.0.1/src/gradientmail/algorithms/__init__.py +279 -0
- gradientmail-0.0.1/src/gradientmail/algorithms/diloco.py +521 -0
- gradientmail-0.0.1/src/gradientmail/clock.py +22 -0
- gradientmail-0.0.1/src/gradientmail/compression.py +80 -0
- gradientmail-0.0.1/src/gradientmail/events.py +478 -0
- gradientmail-0.0.1/src/gradientmail/learner.py +891 -0
- gradientmail-0.0.1/src/gradientmail/lease.py +44 -0
- gradientmail-0.0.1/src/gradientmail/mailbox/__init__.py +57 -0
- gradientmail-0.0.1/src/gradientmail/mailbox/memory.py +39 -0
- gradientmail-0.0.1/src/gradientmail/mailbox/s3.py +210 -0
- gradientmail-0.0.1/src/gradientmail/patterns/__init__.py +4 -0
- gradientmail-0.0.1/src/gradientmail/patterns/barrier.py +67 -0
- gradientmail-0.0.1/src/gradientmail/patterns/shared_queue.py +104 -0
- gradientmail-0.0.1/src/gradientmail/protocol.py +839 -0
- gradientmail-0.0.1/src/gradientmail/serialization.py +241 -0
- gradientmail-0.0.1/src/gradientmail/session.py +195 -0
- gradientmail-0.0.1/src/gradientmail/syncer.py +528 -0
|
@@ -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
|
+

|
|
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
|
+

|
|
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")
|