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/lease.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Learner liveness: a lease per learner, renewed by beats and judged on the observer's clock.
|
|
2
|
+
|
|
3
|
+
A learner's lease is the newest of the create-once beat objects under ``leases/{learner}/``
|
|
4
|
+
(``protocol`` module). The learner renews it every ``lease_every`` seconds while it runs and
|
|
5
|
+
deletes it when it closes. An observer lists the leases on every poll and remembers, per
|
|
6
|
+
learner, the newest beat and the moment on its own clock when it first saw that beat: a
|
|
7
|
+
learner is live while its beat has changed within ``lease_timeout`` seconds, and gone the
|
|
8
|
+
moment its lease leaves the listing. No wall clock crosses machines, so a lease written by a
|
|
9
|
+
node with a skewed clock reads like any other; the cost is that an observer trusts a lease it
|
|
10
|
+
has just met for one timeout before doubting it.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
from dataclasses import dataclass, field, replace
|
|
15
|
+
|
|
16
|
+
__all__ = ["Liveness"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Liveness:
|
|
21
|
+
"""Which learners hold a live lease, as one observer has seen it.
|
|
22
|
+
|
|
23
|
+
``seen`` maps a learner to its newest beat and when this observer first saw that beat.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
timeout: float
|
|
27
|
+
seen: Mapping[str, tuple[int, float]] = field(default_factory=dict)
|
|
28
|
+
|
|
29
|
+
def observed(self, leases: Mapping[str, int], now: float) -> "Liveness":
|
|
30
|
+
"""Fold one listing of the leases, taken at ``now``."""
|
|
31
|
+
|
|
32
|
+
def since(learner: str, beat: int) -> float:
|
|
33
|
+
known = self.seen.get(learner)
|
|
34
|
+
return known[1] if known is not None and known[0] == beat else now
|
|
35
|
+
|
|
36
|
+
return replace(
|
|
37
|
+
self, seen={learner: (beat, since(learner, beat)) for learner, beat in leases.items()}
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def live(self, now: float) -> frozenset[str]:
|
|
41
|
+
"""The learners whose beat has changed within ``timeout`` seconds of ``now``."""
|
|
42
|
+
return frozenset(
|
|
43
|
+
learner for learner, (_, since) in self.seen.items() if now - since < self.timeout
|
|
44
|
+
)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Transport: four operations over immutable, create-once objects.
|
|
2
|
+
|
|
3
|
+
An object is written at most once (``put_if_absent``), read back by key (``get``), discovered
|
|
4
|
+
by prefix in UTF-8 byte order (``list``) and removed idempotently (``delete``). Nothing here
|
|
5
|
+
knows about rounds or snapshots; ``gradientmail.protocol`` builds the exchange on top.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from collections.abc import Awaitable, Callable, Iterable, Sequence
|
|
10
|
+
from typing import Protocol, runtime_checkable
|
|
11
|
+
|
|
12
|
+
from gradientmail.clock import Clock
|
|
13
|
+
|
|
14
|
+
log = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MailboxUnavailable(Exception):
|
|
18
|
+
"""Transient transport failure (timeouts, dropped connections); callers retry."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@runtime_checkable
|
|
22
|
+
class Mailbox(Protocol):
|
|
23
|
+
async def put_if_absent(self, key: str, data: bytes) -> bool:
|
|
24
|
+
"""Create ``key`` holding ``data``; True iff this call created it.
|
|
25
|
+
|
|
26
|
+
An existing object is left untouched and False is returned.
|
|
27
|
+
"""
|
|
28
|
+
...
|
|
29
|
+
|
|
30
|
+
async def get(self, key: str) -> bytes | None:
|
|
31
|
+
"""The bytes under ``key``, or None when no such object exists."""
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
async def list(self, prefix: str, *, after: str = "") -> Sequence[str]:
|
|
35
|
+
"""Every key under ``prefix`` greater than ``after``, in UTF-8 byte order, all pages."""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
async def delete(self, keys: Iterable[str]) -> None:
|
|
39
|
+
"""Remove ``keys`` in any number; missing keys are not an error."""
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def retrying[T](
|
|
44
|
+
op: Callable[[], Awaitable[T]], *, clock: Clock, max_delay: float = 30.0
|
|
45
|
+
) -> T:
|
|
46
|
+
"""Run ``op`` until it returns, sleeping through ``clock`` after each ``MailboxUnavailable``.
|
|
47
|
+
|
|
48
|
+
Delays start at 0.5 s and double up to ``max_delay``. Any other exception propagates at once.
|
|
49
|
+
"""
|
|
50
|
+
delay = min(0.5, max_delay)
|
|
51
|
+
while True:
|
|
52
|
+
try:
|
|
53
|
+
return await op()
|
|
54
|
+
except MailboxUnavailable as exc:
|
|
55
|
+
log.warning("mailbox unavailable: %s; retrying in %.1f s", exc, delay)
|
|
56
|
+
await clock.sleep(delay)
|
|
57
|
+
delay = min(delay * 2, max_delay)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""In-memory Mailbox for development, tests and the Phase 2 example."""
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
from collections.abc import Iterable, Sequence
|
|
5
|
+
|
|
6
|
+
from gradientmail.mailbox import Mailbox
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MemoryMailbox(Mailbox):
|
|
10
|
+
"""A dict behind a ``threading.Lock``.
|
|
11
|
+
|
|
12
|
+
The lock is not an asyncio primitive because one instance is shared between event loops
|
|
13
|
+
running on different threads (a ``Session``'s background loop and a ``Syncer``).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self) -> None:
|
|
17
|
+
self._store: dict[str, bytes] = {}
|
|
18
|
+
self._lock = threading.Lock()
|
|
19
|
+
|
|
20
|
+
async def put_if_absent(self, key: str, data: bytes) -> bool:
|
|
21
|
+
with self._lock:
|
|
22
|
+
if key in self._store:
|
|
23
|
+
return False
|
|
24
|
+
self._store[key] = data
|
|
25
|
+
return True
|
|
26
|
+
|
|
27
|
+
async def get(self, key: str) -> bytes | None:
|
|
28
|
+
with self._lock:
|
|
29
|
+
return self._store.get(key)
|
|
30
|
+
|
|
31
|
+
async def list(self, prefix: str, *, after: str = "") -> Sequence[str]:
|
|
32
|
+
# Code point order is UTF-8 byte order, so str comparison matches S3's listing order.
|
|
33
|
+
with self._lock:
|
|
34
|
+
return sorted(k for k in self._store if k.startswith(prefix) and k > after)
|
|
35
|
+
|
|
36
|
+
async def delete(self, keys: Iterable[str]) -> None:
|
|
37
|
+
with self._lock:
|
|
38
|
+
for key in keys:
|
|
39
|
+
self._store.pop(key, None)
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""S3-compatible object storage as a ``Mailbox``: AWS S3, Cloudflare R2, Hugging Face Storage
|
|
2
|
+
Buckets, MinIO, moto.
|
|
3
|
+
|
|
4
|
+
Consistency assumptions
|
|
5
|
+
-----------------------
|
|
6
|
+
Three properties of the store carry the whole protocol: strong read-after-write and
|
|
7
|
+
list-after-write (AWS general-purpose buckets since 2020-12, R2, MinIO on local xfs, zfs or
|
|
8
|
+
btrfs, moto), an atomic ``If-None-Match: *`` on PUT (AWS since 2024-08, R2, MinIO since
|
|
9
|
+
2024-04, moto since 5.0.15) and ``ListObjectsV2`` in UTF-8 byte order. ``StartAfter`` is sent
|
|
10
|
+
but not relied on: Hugging Face's gateway accepts and ignores it, so ``list`` cuts at ``after``
|
|
11
|
+
itself and a poll there lists the whole prefix. Nothing else is needed because every object is
|
|
12
|
+
written once, payload keys carry the sha256 of their bytes and a record is written after the
|
|
13
|
+
payloads it points at.
|
|
14
|
+
|
|
15
|
+
Hugging Face Storage Buckets are reached at ``https://s3.hf.co/<namespace>`` with
|
|
16
|
+
``request_checksums="when_required"`` and S3 credentials generated from a Hub token; a PUT there
|
|
17
|
+
takes about half a second. Verified with the contract and training suites in October 2026.
|
|
18
|
+
|
|
19
|
+
Not assumed: FIFO delivery, exactly-once, atomicity across keys, server clocks or ETag-as-MD5.
|
|
20
|
+
Without multi-key atomicity a listing may show a payload before its record, so readers follow
|
|
21
|
+
records and never list ``blobs/``.
|
|
22
|
+
|
|
23
|
+
Unsupported: S3 Express directory buckets (unordered listing, no ``StartAfter``), R2 behind a
|
|
24
|
+
caching custom domain (a cached read breaks read-after-write), MinIO on NFS (not a supported
|
|
25
|
+
MinIO backend).
|
|
26
|
+
|
|
27
|
+
Failure modes
|
|
28
|
+
-------------
|
|
29
|
+
A refused connection, a connect or read timeout and a connection closed mid-request become
|
|
30
|
+
``MailboxUnavailable`` once botocore's standard retry mode has made its five attempts; the
|
|
31
|
+
protocol layer then retries with backoff for as long as the outage lasts. A 412
|
|
32
|
+
``PreconditionFailed`` is the create-once contract (``put_if_absent`` returns False). A 409
|
|
33
|
+
``ConditionalRequestConflict`` means another conditional write on the same key was in flight
|
|
34
|
+
and the outcome is unknown; the put is retried three times, then raised as
|
|
35
|
+
``MailboxUnavailable``. Every other ``ClientError`` (a missing bucket, access denied,
|
|
36
|
+
throttling that outlasts botocore's retries) propagates unchanged: on a learner it is raised
|
|
37
|
+
from the ``Session`` constructor while joining and from the next ``step()`` afterwards, on the
|
|
38
|
+
syncer from ``run()``. A payload above 5 GiB raises
|
|
39
|
+
``ValueError`` instead of switching to multipart uploads; choose more fragments. A bulk delete
|
|
40
|
+
reports per-key failures in a 200 response; the first one is raised as a ``ClientError``. On
|
|
41
|
+
AWS and R2 a LIST costs 12.5 times a GET, and every learner and the syncer issue one LIST per
|
|
42
|
+
poll, so ``poll_interval`` is the cost lever.
|
|
43
|
+
|
|
44
|
+
Every call runs in ``asyncio.to_thread``; a ``threading.BoundedSemaphore`` acquired inside the
|
|
45
|
+
thread keeps the connection pool from being oversubscribed. No asyncio primitive lives on the
|
|
46
|
+
instance, so one mailbox can be driven from several event loops in turn.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
import asyncio
|
|
50
|
+
import threading
|
|
51
|
+
from collections.abc import Callable, Iterable, Sequence
|
|
52
|
+
from functools import partial
|
|
53
|
+
from itertools import batched
|
|
54
|
+
from typing import TYPE_CHECKING, Literal
|
|
55
|
+
|
|
56
|
+
import boto3.session
|
|
57
|
+
from botocore.config import Config
|
|
58
|
+
from botocore.exceptions import (
|
|
59
|
+
ClientError,
|
|
60
|
+
ConnectionClosedError,
|
|
61
|
+
ConnectTimeoutError,
|
|
62
|
+
EndpointConnectionError,
|
|
63
|
+
ReadTimeoutError,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
from gradientmail.mailbox import Mailbox, MailboxUnavailable
|
|
67
|
+
|
|
68
|
+
if TYPE_CHECKING:
|
|
69
|
+
from mypy_boto3_s3 import S3Client
|
|
70
|
+
|
|
71
|
+
MAX_OBJECT_BYTES = 5 * 2**30
|
|
72
|
+
"""Largest single PUT S3 accepts; larger payloads need more fragments, not multipart uploads."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class S3Mailbox(Mailbox):
|
|
76
|
+
"""Keys live under ``prefix`` in ``bucket``; credentials come from the boto3 default chain.
|
|
77
|
+
|
|
78
|
+
Parameters
|
|
79
|
+
----------
|
|
80
|
+
endpoint_url
|
|
81
|
+
None for AWS; the service URL for R2, MinIO or any other S3-compatible store.
|
|
82
|
+
region
|
|
83
|
+
Signing region; None lets boto3 resolve it from the environment.
|
|
84
|
+
max_concurrency
|
|
85
|
+
Requests in flight at once, which is also the size of the connection pool.
|
|
86
|
+
request_checksums
|
|
87
|
+
``"when_supported"`` asks the store to verify a SHA-256 of every uploaded object;
|
|
88
|
+
``"when_required"`` sends only the checksums the protocol mandates, for stores that
|
|
89
|
+
reject flexible checksums (Hugging Face Storage Buckets).
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
bucket: str,
|
|
95
|
+
prefix: str,
|
|
96
|
+
*,
|
|
97
|
+
endpoint_url: str | None = None,
|
|
98
|
+
region: str | None = None,
|
|
99
|
+
max_concurrency: int = 8,
|
|
100
|
+
request_checksums: Literal["when_supported", "when_required"] = "when_supported",
|
|
101
|
+
) -> None:
|
|
102
|
+
self._bucket = bucket
|
|
103
|
+
self._prefix = f"{prefix.strip('/')}/" if prefix.strip("/") else ""
|
|
104
|
+
self._slots = threading.BoundedSemaphore(max_concurrency)
|
|
105
|
+
self._client: S3Client = boto3.session.Session().client(
|
|
106
|
+
"s3",
|
|
107
|
+
endpoint_url=endpoint_url,
|
|
108
|
+
region_name=region,
|
|
109
|
+
config=Config(
|
|
110
|
+
retries={"mode": "standard", "total_max_attempts": 5},
|
|
111
|
+
max_pool_connections=max_concurrency,
|
|
112
|
+
connect_timeout=10,
|
|
113
|
+
read_timeout=120,
|
|
114
|
+
s3={"addressing_style": "path"},
|
|
115
|
+
request_checksum_calculation=request_checksums,
|
|
116
|
+
response_checksum_validation=request_checksums,
|
|
117
|
+
),
|
|
118
|
+
)
|
|
119
|
+
put_object = partial(self._client.put_object, Bucket=bucket, IfNoneMatch="*")
|
|
120
|
+
self._put_object = (
|
|
121
|
+
partial(put_object, ChecksumAlgorithm="SHA256")
|
|
122
|
+
if request_checksums == "when_supported"
|
|
123
|
+
else put_object
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
async def put_if_absent(self, key: str, data: bytes) -> bool:
|
|
127
|
+
if len(data) > MAX_OBJECT_BYTES:
|
|
128
|
+
raise ValueError(
|
|
129
|
+
f"{key}: {len(data)} bytes exceeds the 5 GiB single-object limit; "
|
|
130
|
+
"use more fragments"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def put() -> bool:
|
|
134
|
+
# A 409 ConditionalRequestConflict means another conditional write on the same
|
|
135
|
+
# key was in progress; the outcome is unknown until one of them has landed.
|
|
136
|
+
for _ in range(3):
|
|
137
|
+
try:
|
|
138
|
+
self._put_object(Key=self._prefix + key, Body=data)
|
|
139
|
+
except ClientError as exc:
|
|
140
|
+
code = exc.response.get("Error", {}).get("Code")
|
|
141
|
+
if code == "PreconditionFailed":
|
|
142
|
+
return False
|
|
143
|
+
if code != "ConditionalRequestConflict":
|
|
144
|
+
raise
|
|
145
|
+
else:
|
|
146
|
+
return True
|
|
147
|
+
raise MailboxUnavailable(f"{key}: conditional write conflicted three times")
|
|
148
|
+
|
|
149
|
+
return await self._call(put)
|
|
150
|
+
|
|
151
|
+
async def get(self, key: str) -> bytes | None:
|
|
152
|
+
def get() -> bytes | None:
|
|
153
|
+
try:
|
|
154
|
+
response = self._client.get_object(Bucket=self._bucket, Key=self._prefix + key)
|
|
155
|
+
except self._client.exceptions.NoSuchKey:
|
|
156
|
+
return None
|
|
157
|
+
return response["Body"].read()
|
|
158
|
+
|
|
159
|
+
return await self._call(get)
|
|
160
|
+
|
|
161
|
+
async def list(self, prefix: str, *, after: str = "") -> Sequence[str]:
|
|
162
|
+
def list_keys() -> Sequence[str]:
|
|
163
|
+
paginator = self._client.get_paginator("list_objects_v2")
|
|
164
|
+
full_prefix = self._prefix + prefix
|
|
165
|
+
pages = (
|
|
166
|
+
paginator.paginate(
|
|
167
|
+
Bucket=self._bucket, Prefix=full_prefix, StartAfter=self._prefix + after
|
|
168
|
+
)
|
|
169
|
+
if after
|
|
170
|
+
else paginator.paginate(Bucket=self._bucket, Prefix=full_prefix)
|
|
171
|
+
)
|
|
172
|
+
# Hugging Face's gateway accepts StartAfter and ignores it, so the cut is made here
|
|
173
|
+
# as well; on stores that honour it the comparison never drops anything.
|
|
174
|
+
return tuple(
|
|
175
|
+
stripped
|
|
176
|
+
for page in pages
|
|
177
|
+
for obj in page.get("Contents", ())
|
|
178
|
+
if (key := obj.get("Key")) is not None
|
|
179
|
+
and (stripped := key[len(self._prefix) :]) > after
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
return await self._call(list_keys)
|
|
183
|
+
|
|
184
|
+
async def delete(self, keys: Iterable[str]) -> None:
|
|
185
|
+
def delete_chunk(chunk: Sequence[str]) -> None:
|
|
186
|
+
response = self._client.delete_objects(
|
|
187
|
+
Bucket=self._bucket,
|
|
188
|
+
Delete={"Objects": [{"Key": self._prefix + key} for key in chunk], "Quiet": True},
|
|
189
|
+
)
|
|
190
|
+
# Per-key failures arrive in a 200 response, not as an exception.
|
|
191
|
+
if errors := response.get("Errors"):
|
|
192
|
+
raise ClientError({"Error": errors[0]}, "DeleteObjects")
|
|
193
|
+
|
|
194
|
+
for chunk in batched(keys, 1000):
|
|
195
|
+
await self._call(partial(delete_chunk, chunk))
|
|
196
|
+
|
|
197
|
+
async def _call[T](self, op: Callable[[], T]) -> T:
|
|
198
|
+
def guarded() -> T:
|
|
199
|
+
with self._slots:
|
|
200
|
+
try:
|
|
201
|
+
return op()
|
|
202
|
+
except (
|
|
203
|
+
EndpointConnectionError,
|
|
204
|
+
ConnectTimeoutError,
|
|
205
|
+
ReadTimeoutError,
|
|
206
|
+
ConnectionClosedError,
|
|
207
|
+
) as exc:
|
|
208
|
+
raise MailboxUnavailable(str(exc)) from exc
|
|
209
|
+
|
|
210
|
+
return await asyncio.to_thread(guarded)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Hold nodes at a named point until a number of them have arrived."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from functools import partial
|
|
5
|
+
|
|
6
|
+
from gradientmail.clock import Clock, MonotonicClock
|
|
7
|
+
from gradientmail.mailbox import Mailbox, retrying
|
|
8
|
+
|
|
9
|
+
__all__ = ["Barrier"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Barrier:
|
|
13
|
+
"""A rendezvous through the mailbox: ``wait(name, parties=n)`` returns once ``n`` nodes arrived.
|
|
14
|
+
|
|
15
|
+
Nodes share a run-scoped mailbox, with a distinct learner ID per node. An arrival is the
|
|
16
|
+
create-once object ``barriers/{name}/{learner_id}``; ``wait`` creates this node's and lists
|
|
17
|
+
the prefix every ``poll_interval`` seconds until it holds ``parties`` keys or more. Arrivals
|
|
18
|
+
are never removed: a name serves once per run, a loop names each barrier (``f"epoch-{n}"``)
|
|
19
|
+
and a node arriving after the barrier opened passes at once. Transient mailbox failures are
|
|
20
|
+
retried. ``clock`` is the time source for polling and the timeout.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
*,
|
|
26
|
+
mailbox: Mailbox,
|
|
27
|
+
learner_id: str,
|
|
28
|
+
poll_interval: float = 1.0,
|
|
29
|
+
clock: Clock | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
self._mailbox = mailbox
|
|
32
|
+
self._learner_id = learner_id
|
|
33
|
+
self._poll_interval = poll_interval
|
|
34
|
+
self._clock = clock if clock is not None else MonotonicClock()
|
|
35
|
+
|
|
36
|
+
def wait(self, name: str, *, parties: int, timeout: float | None = None) -> frozenset[str]:
|
|
37
|
+
"""Record this node's arrival at ``name`` and block until ``parties`` nodes have arrived.
|
|
38
|
+
|
|
39
|
+
``parties`` is a count the caller fixes; it need not match the algorithm's quorum.
|
|
40
|
+
Returns the learner IDs that had arrived when the barrier opened. Raises ``TimeoutError``
|
|
41
|
+
after ``timeout`` seconds without opening; the arrival stays recorded, so calling again
|
|
42
|
+
resumes the wait. Calling again once the barrier is open returns at once.
|
|
43
|
+
"""
|
|
44
|
+
if parties < 1:
|
|
45
|
+
raise ValueError("parties must be positive")
|
|
46
|
+
return asyncio.run(self._wait(name, parties, timeout))
|
|
47
|
+
|
|
48
|
+
async def _wait(self, name: str, parties: int, timeout: float | None) -> frozenset[str]:
|
|
49
|
+
prefix = f"barriers/{name}/"
|
|
50
|
+
deadline = None if timeout is None else self._clock.now() + timeout
|
|
51
|
+
await retrying(
|
|
52
|
+
partial(self._mailbox.put_if_absent, f"{prefix}{self._learner_id}", b""),
|
|
53
|
+
clock=self._clock,
|
|
54
|
+
)
|
|
55
|
+
while True:
|
|
56
|
+
arrived = await retrying(partial(self._mailbox.list, prefix), clock=self._clock)
|
|
57
|
+
if len(arrived) >= parties:
|
|
58
|
+
return frozenset(key.removeprefix(prefix) for key in arrived)
|
|
59
|
+
delay = self._poll_interval
|
|
60
|
+
if deadline is not None:
|
|
61
|
+
remaining = deadline - self._clock.now()
|
|
62
|
+
if remaining <= 0:
|
|
63
|
+
raise TimeoutError(
|
|
64
|
+
f"{len(arrived)} of {parties} nodes arrived at {name!r} within {timeout} s"
|
|
65
|
+
)
|
|
66
|
+
delay = min(delay, remaining)
|
|
67
|
+
await self._clock.sleep(delay)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Distribute work through mailbox claims and prefetch its loaded items."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections import deque
|
|
5
|
+
from collections.abc import Callable, Generator, Iterator, Mapping
|
|
6
|
+
from contextlib import closing, contextmanager
|
|
7
|
+
from threading import Condition, Thread
|
|
8
|
+
|
|
9
|
+
from gradientmail.mailbox import Mailbox
|
|
10
|
+
|
|
11
|
+
__all__ = ["shared_queue"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@contextmanager
|
|
15
|
+
def shared_queue[Work, Batch](
|
|
16
|
+
units: Mapping[str, Work],
|
|
17
|
+
*,
|
|
18
|
+
mailbox: Mailbox,
|
|
19
|
+
learner_id: str,
|
|
20
|
+
load: Callable[[Work], Iterator[Batch]],
|
|
21
|
+
prefetch: int = 8,
|
|
22
|
+
) -> Iterator[Iterator[Batch]]:
|
|
23
|
+
"""Reserve units in mapping order and load their items on a background thread.
|
|
24
|
+
|
|
25
|
+
Nodes share unit identifiers and a run-scoped mailbox, with a distinct learner ID
|
|
26
|
+
per node. Values stay local; reservations use ``units/{identifier}`` keys. Exclusive
|
|
27
|
+
ownership requires atomic ``put_if_absent``. Claims remain after completion or failure.
|
|
28
|
+
|
|
29
|
+
At most ``prefetch`` loaded items wait for consumption. Loader and mailbox failures
|
|
30
|
+
propagate after buffered items. Exhaustion means no unclaimed work remains, not that
|
|
31
|
+
other nodes have finished. Exiting stops prefetch and joins the thread, waiting for
|
|
32
|
+
any current mailbox or loader operation to return.
|
|
33
|
+
"""
|
|
34
|
+
if prefetch < 1:
|
|
35
|
+
raise ValueError("prefetch must be positive")
|
|
36
|
+
|
|
37
|
+
owner = learner_id.encode()
|
|
38
|
+
condition = Condition()
|
|
39
|
+
pending = deque[Batch]()
|
|
40
|
+
stopped = False
|
|
41
|
+
finished = False
|
|
42
|
+
failure: BaseException | None = None
|
|
43
|
+
|
|
44
|
+
async def claim(key: str) -> bool:
|
|
45
|
+
return await mailbox.put_if_absent(key, owner) and await mailbox.get(key) == owner
|
|
46
|
+
|
|
47
|
+
def loaded() -> Generator[Batch, None, None]:
|
|
48
|
+
with asyncio.Runner() as runner:
|
|
49
|
+
while True:
|
|
50
|
+
taken = set(runner.run(mailbox.list("units/")))
|
|
51
|
+
for identifier, work in units.items():
|
|
52
|
+
key = f"units/{identifier}"
|
|
53
|
+
if key not in taken and runner.run(claim(key)):
|
|
54
|
+
yield from load(work)
|
|
55
|
+
break
|
|
56
|
+
else:
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
def produce() -> None:
|
|
60
|
+
nonlocal finished, failure
|
|
61
|
+
try:
|
|
62
|
+
with closing(loaded()) as source:
|
|
63
|
+
while True:
|
|
64
|
+
with condition:
|
|
65
|
+
condition.wait_for(lambda: stopped or len(pending) < prefetch)
|
|
66
|
+
if stopped:
|
|
67
|
+
return
|
|
68
|
+
batch = next(source)
|
|
69
|
+
with condition:
|
|
70
|
+
pending.append(batch)
|
|
71
|
+
condition.notify_all()
|
|
72
|
+
except StopIteration:
|
|
73
|
+
pass
|
|
74
|
+
except BaseException as error:
|
|
75
|
+
failure = error
|
|
76
|
+
finally:
|
|
77
|
+
with condition:
|
|
78
|
+
finished = True
|
|
79
|
+
condition.notify_all()
|
|
80
|
+
|
|
81
|
+
def consume() -> Iterator[Batch]:
|
|
82
|
+
while True:
|
|
83
|
+
with condition:
|
|
84
|
+
condition.wait_for(lambda: stopped or pending or finished)
|
|
85
|
+
if stopped:
|
|
86
|
+
return
|
|
87
|
+
if not pending:
|
|
88
|
+
if failure is not None:
|
|
89
|
+
raise failure
|
|
90
|
+
return
|
|
91
|
+
batch = pending.popleft()
|
|
92
|
+
condition.notify_all()
|
|
93
|
+
yield batch
|
|
94
|
+
|
|
95
|
+
thread = Thread(target=produce, name=f"shared-queue-{learner_id}")
|
|
96
|
+
thread.start()
|
|
97
|
+
try:
|
|
98
|
+
yield consume()
|
|
99
|
+
finally:
|
|
100
|
+
with condition:
|
|
101
|
+
stopped = True
|
|
102
|
+
condition.notify_all()
|
|
103
|
+
thread.join()
|
|
104
|
+
pending.clear()
|