singularic 1.0.0__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.
- singularic/__init__.py +13 -0
- singularic/checkpoint/bundle.py +59 -0
- singularic/checkpoint/manager.py +350 -0
- singularic/checkpoint/store.py +107 -0
- singularic/core/apply.py +62 -0
- singularic/core/canonical.py +332 -0
- singularic/core/context.py +51 -0
- singularic/core/enums.py +43 -0
- singularic/core/module.py +970 -0
- singularic/core/path.py +114 -0
- singularic/core/placement.py +27 -0
- singularic/core/precision.py +322 -0
- singularic/core/rng.py +191 -0
- singularic/core/schema.py +302 -0
- singularic/core/selectors.py +151 -0
- singularic/core/state.py +134 -0
- singularic/core/train_state.py +152 -0
- singularic/core/variables.py +412 -0
- singularic/data/dataset.py +555 -0
- singularic/data/ops.py +982 -0
- singularic/data/runtime.py +758 -0
- singularic/data/spec.py +459 -0
- singularic/distributed/sharding.py +366 -0
- singularic/inference/attention.py +143 -0
- singularic/inference/cache.py +612 -0
- singularic/inference/engine.py +398 -0
- singularic/inference/sampling.py +154 -0
- singularic/inspect/report.py +277 -0
- singularic/nn/attention.py +467 -0
- singularic/nn/containers.py +228 -0
- singularic/nn/functional.py +164 -0
- singularic/nn/initializers.py +255 -0
- singularic/nn/kernels.py +532 -0
- singularic/nn/layers.py +594 -0
- singularic/nn/moe.py +482 -0
- singularic/nn/paged.py +70 -0
- singularic/nn/transformer.py +566 -0
- singularic/nn/vision.py +158 -0
- singularic/optim/algorithms.py +278 -0
- singularic/optim/muon.py +91 -0
- singularic/optim/runtime.py +147 -0
- singularic/optim/schedules.py +152 -0
- singularic/optim/specs.py +161 -0
- singularic/optim/transforms.py +460 -0
- singularic/py.typed +1 -0
- singularic/train/checkpoint.py +93 -0
- singularic/train/compiler.py +162 -0
- singularic/train/metrics.py +215 -0
- singularic/train/specs.py +203 -0
- singularic/train/trainer.py +621 -0
- singularic/train/transitions.py +442 -0
- singularic-1.0.0.dist-info/METADATA +152 -0
- singularic-1.0.0.dist-info/RECORD +55 -0
- singularic-1.0.0.dist-info/WHEEL +4 -0
- singularic-1.0.0.dist-info/licenses/LICENSE +201 -0
singularic/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Singularic's intentionally minimal package marker.
|
|
2
|
+
|
|
3
|
+
Stable APIs are imported from their owning modules. Child directories are
|
|
4
|
+
PEP 420 namespaces and deliberately have no package-barrel initializers.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from importlib.metadata import version as _distribution_version
|
|
8
|
+
from typing import Final
|
|
9
|
+
|
|
10
|
+
from .core.apply import apply as apply
|
|
11
|
+
from .core.state import State as State
|
|
12
|
+
|
|
13
|
+
__version__: Final = _distribution_version("singularic")
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Generic portable checkpoint values independent of training orchestration."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from types import MappingProxyType
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from singularic.core.state import State
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _json_value(value: Mapping[str, Any] | None, description: str) -> Mapping[str, Any]:
|
|
13
|
+
resolved = {} if value is None else dict(value)
|
|
14
|
+
try:
|
|
15
|
+
json.dumps(resolved, allow_nan=False, sort_keys=True)
|
|
16
|
+
except (TypeError, ValueError) as error:
|
|
17
|
+
raise TypeError(
|
|
18
|
+
f"CheckpointBundle {description} must be JSON-compatible"
|
|
19
|
+
) from error
|
|
20
|
+
return MappingProxyType(resolved)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class CheckpointBundle:
|
|
25
|
+
"""Named portable :class:`State` groups plus JSON metadata and cursor data.
|
|
26
|
+
|
|
27
|
+
The bundle has no knowledge of models, optimizers, or trainers. Group names
|
|
28
|
+
are stable storage ownership boundaries, allowing a caller to restore just
|
|
29
|
+
the local state it owns.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
groups: Mapping[str, State]
|
|
33
|
+
metadata: Mapping[str, Any] | None = None
|
|
34
|
+
dataset_cursor: Mapping[str, Any] | None = None
|
|
35
|
+
|
|
36
|
+
def __post_init__(self) -> None:
|
|
37
|
+
normalized = dict(self.groups)
|
|
38
|
+
if not normalized or any(
|
|
39
|
+
not isinstance(name, str) or not name for name in normalized
|
|
40
|
+
):
|
|
41
|
+
raise ValueError("CheckpointBundle groups must have non-empty string names")
|
|
42
|
+
if any(not isinstance(state, State) for state in normalized.values()):
|
|
43
|
+
raise TypeError("CheckpointBundle groups must be State values")
|
|
44
|
+
object.__setattr__(self, "groups", MappingProxyType(normalized))
|
|
45
|
+
object.__setattr__(self, "metadata", _json_value(self.metadata, "metadata"))
|
|
46
|
+
object.__setattr__(
|
|
47
|
+
self, "dataset_cursor", _json_value(self.dataset_cursor, "dataset cursor")
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
def subset(self, names: tuple[str, ...]) -> CheckpointBundle:
|
|
51
|
+
if not names or len(names) != len(set(names)):
|
|
52
|
+
raise ValueError(
|
|
53
|
+
"CheckpointBundle subset names must be non-empty and unique"
|
|
54
|
+
)
|
|
55
|
+
try:
|
|
56
|
+
groups = {name: self.groups[name] for name in names}
|
|
57
|
+
except KeyError as error:
|
|
58
|
+
raise KeyError(f"Unknown checkpoint group {error.args[0]!r}") from error
|
|
59
|
+
return CheckpointBundle(groups, self.metadata, self.dataset_cursor)
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""Immutable generic checkpoint bundles over a minimal byte-store protocol."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import threading
|
|
7
|
+
import uuid
|
|
8
|
+
from collections.abc import Callable, Mapping
|
|
9
|
+
from concurrent.futures import Future, ThreadPoolExecutor
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Final, Self
|
|
13
|
+
|
|
14
|
+
import jax
|
|
15
|
+
import jax.numpy as jnp
|
|
16
|
+
import numpy as np
|
|
17
|
+
from safetensors.numpy import load, save
|
|
18
|
+
|
|
19
|
+
from singularic.core.state import State, StateSchema
|
|
20
|
+
|
|
21
|
+
from .bundle import CheckpointBundle
|
|
22
|
+
from .store import ByteStore, PosixCheckpointStore, normalize_storage_key
|
|
23
|
+
|
|
24
|
+
_FORMAT: Final = "singularic-checkpoint-bundle"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _json_bytes(value: Mapping[str, Any]) -> bytes:
|
|
28
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":")).encode()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _json_object(payload: bytes, description: str) -> dict[str, Any]:
|
|
32
|
+
try:
|
|
33
|
+
value = json.loads(payload)
|
|
34
|
+
except (UnicodeDecodeError, ValueError) as error:
|
|
35
|
+
raise ValueError(f"Invalid {description} JSON") from error
|
|
36
|
+
if not isinstance(value, dict):
|
|
37
|
+
raise ValueError(f"{description} must be a JSON object")
|
|
38
|
+
return value
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True, order=True)
|
|
42
|
+
class CheckpointRef:
|
|
43
|
+
"""One atomically published immutable bundle generation."""
|
|
44
|
+
|
|
45
|
+
step: int
|
|
46
|
+
generation: str
|
|
47
|
+
commit_key: str
|
|
48
|
+
|
|
49
|
+
def __post_init__(self) -> None:
|
|
50
|
+
if self.step < 0 or not re.fullmatch(r"[0-9a-f]{32}", self.generation):
|
|
51
|
+
raise ValueError("Invalid checkpoint reference")
|
|
52
|
+
normalize_storage_key(self.commit_key)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True, slots=True)
|
|
56
|
+
class CheckpointRestore:
|
|
57
|
+
bundle: CheckpointBundle
|
|
58
|
+
checkpoint: CheckpointRef
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class SaveHandle:
|
|
62
|
+
"""Completion boundary for one asynchronous immutable publication."""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self, future: Future[CheckpointRef], consume: Callable[[], None]
|
|
66
|
+
) -> None:
|
|
67
|
+
self._future = future
|
|
68
|
+
self._consume = consume
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def done(self) -> bool:
|
|
72
|
+
return self._future.done()
|
|
73
|
+
|
|
74
|
+
def result(self, timeout: float | None = None) -> CheckpointRef:
|
|
75
|
+
try:
|
|
76
|
+
return self._future.result(timeout=timeout)
|
|
77
|
+
finally:
|
|
78
|
+
if self._future.done():
|
|
79
|
+
self._consume()
|
|
80
|
+
|
|
81
|
+
wait = result
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class CheckpointManager:
|
|
85
|
+
"""Persist and restore generic named state groups without Trainer knowledge."""
|
|
86
|
+
|
|
87
|
+
def __init__(
|
|
88
|
+
self,
|
|
89
|
+
directory: str | Path | None = None,
|
|
90
|
+
*,
|
|
91
|
+
store: ByteStore | None = None,
|
|
92
|
+
max_to_keep: int | None = None,
|
|
93
|
+
) -> None:
|
|
94
|
+
if (directory is None) == (store is None):
|
|
95
|
+
raise ValueError("Provide exactly one of directory or store")
|
|
96
|
+
if max_to_keep is not None and max_to_keep <= 0:
|
|
97
|
+
raise ValueError("max_to_keep must be positive")
|
|
98
|
+
if store is None:
|
|
99
|
+
assert directory is not None
|
|
100
|
+
self.store: ByteStore = PosixCheckpointStore(Path(directory))
|
|
101
|
+
else:
|
|
102
|
+
self.store = store
|
|
103
|
+
if not isinstance(self.store, ByteStore):
|
|
104
|
+
raise TypeError("store must implement ByteStore")
|
|
105
|
+
self.max_to_keep = max_to_keep
|
|
106
|
+
self._lock = threading.Lock()
|
|
107
|
+
self._executor: ThreadPoolExecutor | None = None
|
|
108
|
+
self._pending: set[Future[CheckpointRef]] = set()
|
|
109
|
+
self._closed = False
|
|
110
|
+
|
|
111
|
+
def __enter__(self) -> Self:
|
|
112
|
+
return self
|
|
113
|
+
|
|
114
|
+
def __exit__(self, *_: object) -> None:
|
|
115
|
+
self.close()
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
def _commit_key(step: int) -> str:
|
|
119
|
+
return f"commits/step-{step:012d}.json"
|
|
120
|
+
|
|
121
|
+
def _put(self, key: str, payload: bytes) -> None:
|
|
122
|
+
if not self.store.put_if_absent(key, payload):
|
|
123
|
+
raise FileExistsError(f"Immutable checkpoint key already exists: {key}")
|
|
124
|
+
|
|
125
|
+
def save(self, bundle: CheckpointBundle, *, step: int) -> CheckpointRef:
|
|
126
|
+
"""Write all named groups, then publish one conditional commit record."""
|
|
127
|
+
|
|
128
|
+
if not isinstance(bundle, CheckpointBundle):
|
|
129
|
+
raise TypeError("bundle must be a CheckpointBundle")
|
|
130
|
+
if step < 0:
|
|
131
|
+
raise ValueError("Checkpoint step must be nonnegative")
|
|
132
|
+
commit_key = self._commit_key(step)
|
|
133
|
+
if self.store.exists(commit_key):
|
|
134
|
+
raise FileExistsError(f"Checkpoint step {step} is already committed")
|
|
135
|
+
generation = uuid.uuid4().hex
|
|
136
|
+
prefix = f"generations/{generation}"
|
|
137
|
+
arrays: dict[str, np.ndarray[Any, Any]] = {}
|
|
138
|
+
groups: dict[str, Any] = {}
|
|
139
|
+
for group_name, state in bundle.groups.items():
|
|
140
|
+
tensors: list[dict[str, Any]] = []
|
|
141
|
+
for index, value in enumerate(state.values):
|
|
142
|
+
name = f"{group_name}:{index}"
|
|
143
|
+
typed_key = bool(jnp.issubdtype(value.dtype, jax.dtypes.prng_key))
|
|
144
|
+
physical = jax.random.key_data(value) if typed_key else value
|
|
145
|
+
arrays[name] = np.asarray(jax.device_get(physical))
|
|
146
|
+
tensors.append(
|
|
147
|
+
{
|
|
148
|
+
"name": name,
|
|
149
|
+
"typed_key": typed_key,
|
|
150
|
+
"key_impl": str(jax.random.key_impl(value))
|
|
151
|
+
if typed_key
|
|
152
|
+
else None,
|
|
153
|
+
}
|
|
154
|
+
)
|
|
155
|
+
groups[group_name] = {"schema": state.schema.to_wire(), "tensors": tensors}
|
|
156
|
+
payload = save(arrays)
|
|
157
|
+
metadata = _json_bytes(
|
|
158
|
+
{
|
|
159
|
+
"format": _FORMAT,
|
|
160
|
+
"step": step,
|
|
161
|
+
"generation": generation,
|
|
162
|
+
"groups": groups,
|
|
163
|
+
"metadata": dict(bundle.metadata or {}),
|
|
164
|
+
"dataset_cursor": dict(bundle.dataset_cursor or {}),
|
|
165
|
+
"arrays_sha256": hashlib.sha256(payload).hexdigest(),
|
|
166
|
+
}
|
|
167
|
+
)
|
|
168
|
+
arrays_key = f"{prefix}/arrays.safetensors"
|
|
169
|
+
metadata_key = f"{prefix}/metadata.json"
|
|
170
|
+
commit = _json_bytes(
|
|
171
|
+
{
|
|
172
|
+
"format": _FORMAT,
|
|
173
|
+
"step": step,
|
|
174
|
+
"generation": generation,
|
|
175
|
+
"metadata_key": metadata_key,
|
|
176
|
+
"metadata_sha256": hashlib.sha256(metadata).hexdigest(),
|
|
177
|
+
}
|
|
178
|
+
)
|
|
179
|
+
self._put(arrays_key, payload)
|
|
180
|
+
self._put(metadata_key, metadata)
|
|
181
|
+
self._put(commit_key, commit)
|
|
182
|
+
reference = CheckpointRef(step, generation, commit_key)
|
|
183
|
+
self._prune()
|
|
184
|
+
return reference
|
|
185
|
+
|
|
186
|
+
def save_async(self, bundle: CheckpointBundle, *, step: int) -> SaveHandle:
|
|
187
|
+
with self._lock:
|
|
188
|
+
if self._closed:
|
|
189
|
+
raise RuntimeError("Checkpoint manager is closed")
|
|
190
|
+
if self._executor is None:
|
|
191
|
+
self._executor = ThreadPoolExecutor(max_workers=1)
|
|
192
|
+
future = self._executor.submit(self.save, bundle, step=step)
|
|
193
|
+
self._pending.add(future)
|
|
194
|
+
|
|
195
|
+
def consume() -> None:
|
|
196
|
+
with self._lock:
|
|
197
|
+
self._pending.discard(future)
|
|
198
|
+
|
|
199
|
+
return SaveHandle(future, consume)
|
|
200
|
+
|
|
201
|
+
def wait_for_pending(self) -> None:
|
|
202
|
+
with self._lock:
|
|
203
|
+
pending = tuple(self._pending)
|
|
204
|
+
for future in pending:
|
|
205
|
+
try:
|
|
206
|
+
future.result()
|
|
207
|
+
finally:
|
|
208
|
+
with self._lock:
|
|
209
|
+
self._pending.discard(future)
|
|
210
|
+
|
|
211
|
+
def close(self) -> None:
|
|
212
|
+
with self._lock:
|
|
213
|
+
if self._closed:
|
|
214
|
+
return
|
|
215
|
+
self._closed = True
|
|
216
|
+
executor = self._executor
|
|
217
|
+
self.wait_for_pending()
|
|
218
|
+
if executor is not None:
|
|
219
|
+
executor.shutdown(wait=True)
|
|
220
|
+
|
|
221
|
+
def checkpoints(self) -> tuple[CheckpointRef, ...]:
|
|
222
|
+
records: list[CheckpointRef] = []
|
|
223
|
+
for key in self.store.list("commits/"):
|
|
224
|
+
record = _json_object(self.store.get(key), "checkpoint commit")
|
|
225
|
+
if record.get("format") != _FORMAT:
|
|
226
|
+
raise ValueError("Unsupported checkpoint commit")
|
|
227
|
+
records.append(
|
|
228
|
+
CheckpointRef(int(record["step"]), str(record["generation"]), key)
|
|
229
|
+
)
|
|
230
|
+
return tuple(sorted(records))
|
|
231
|
+
|
|
232
|
+
def latest(self) -> CheckpointRef:
|
|
233
|
+
records = self.checkpoints()
|
|
234
|
+
if not records:
|
|
235
|
+
raise FileNotFoundError("Checkpoint store contains no committed bundles")
|
|
236
|
+
return records[-1]
|
|
237
|
+
|
|
238
|
+
def restore(
|
|
239
|
+
self,
|
|
240
|
+
checkpoint: CheckpointRef | str | None = None,
|
|
241
|
+
*,
|
|
242
|
+
targets: Mapping[str, State] | None = None,
|
|
243
|
+
) -> CheckpointRestore:
|
|
244
|
+
reference = self.latest() if checkpoint is None else self._reference(checkpoint)
|
|
245
|
+
commit = _json_object(self.store.get(reference.commit_key), "checkpoint commit")
|
|
246
|
+
if commit.get("format") != _FORMAT:
|
|
247
|
+
raise ValueError("Unsupported checkpoint commit")
|
|
248
|
+
if (
|
|
249
|
+
commit.get("step") != reference.step
|
|
250
|
+
or commit.get("generation") != reference.generation
|
|
251
|
+
):
|
|
252
|
+
raise ValueError("Checkpoint reference does not match its commit")
|
|
253
|
+
metadata_key = str(commit["metadata_key"])
|
|
254
|
+
if metadata_key != f"generations/{reference.generation}/metadata.json":
|
|
255
|
+
raise ValueError("Checkpoint metadata key does not match its generation")
|
|
256
|
+
metadata_payload = self.store.get(normalize_storage_key(metadata_key))
|
|
257
|
+
if hashlib.sha256(metadata_payload).hexdigest() != commit.get(
|
|
258
|
+
"metadata_sha256"
|
|
259
|
+
):
|
|
260
|
+
raise ValueError("Checkpoint metadata checksum mismatch")
|
|
261
|
+
metadata = _json_object(metadata_payload, "checkpoint metadata")
|
|
262
|
+
if metadata.get("format") != _FORMAT:
|
|
263
|
+
raise ValueError("Unsupported checkpoint metadata")
|
|
264
|
+
if (
|
|
265
|
+
metadata.get("step") != reference.step
|
|
266
|
+
or metadata.get("generation") != reference.generation
|
|
267
|
+
):
|
|
268
|
+
raise ValueError("Checkpoint metadata does not match its commit")
|
|
269
|
+
arrays_key = f"generations/{reference.generation}/arrays.safetensors"
|
|
270
|
+
arrays_payload = self.store.get(arrays_key)
|
|
271
|
+
if hashlib.sha256(arrays_payload).hexdigest() != metadata.get("arrays_sha256"):
|
|
272
|
+
raise ValueError("Checkpoint array checksum mismatch")
|
|
273
|
+
decoded = load(arrays_payload)
|
|
274
|
+
restored: dict[str, State] = {}
|
|
275
|
+
raw_groups = metadata.get("groups")
|
|
276
|
+
if not isinstance(raw_groups, Mapping):
|
|
277
|
+
raise ValueError("Checkpoint groups must be an object")
|
|
278
|
+
expected_names: set[str] = set()
|
|
279
|
+
for name, raw_group in raw_groups.items():
|
|
280
|
+
if not isinstance(name, str) or not isinstance(raw_group, Mapping):
|
|
281
|
+
raise ValueError("Invalid checkpoint group")
|
|
282
|
+
schema = StateSchema.from_wire(raw_group["schema"])
|
|
283
|
+
tensors = raw_group["tensors"]
|
|
284
|
+
if not isinstance(tensors, list) or len(tensors) != len(schema):
|
|
285
|
+
raise ValueError("Checkpoint tensors do not match their schema")
|
|
286
|
+
values: list[jax.Array] = []
|
|
287
|
+
target = None if targets is None else targets.get(name)
|
|
288
|
+
if target is not None and target.schema != schema:
|
|
289
|
+
raise ValueError(f"Checkpoint group {name!r} does not match its target")
|
|
290
|
+
for index, item in enumerate(tensors):
|
|
291
|
+
if not isinstance(item, Mapping) or not isinstance(
|
|
292
|
+
item.get("name"), str
|
|
293
|
+
):
|
|
294
|
+
raise ValueError("Invalid checkpoint tensor record")
|
|
295
|
+
tensor_name = item["name"]
|
|
296
|
+
if tensor_name != f"{name}:{index}" or tensor_name in expected_names:
|
|
297
|
+
raise ValueError("Checkpoint tensor names are inconsistent")
|
|
298
|
+
expected_names.add(tensor_name)
|
|
299
|
+
try:
|
|
300
|
+
value = jnp.asarray(decoded[tensor_name])
|
|
301
|
+
except KeyError as error:
|
|
302
|
+
raise ValueError(
|
|
303
|
+
"Checkpoint array payload is incomplete"
|
|
304
|
+
) from error
|
|
305
|
+
if bool(item.get("typed_key")):
|
|
306
|
+
impl = item.get("key_impl")
|
|
307
|
+
if not isinstance(impl, str):
|
|
308
|
+
raise ValueError(
|
|
309
|
+
"Typed key checkpoint is missing its implementation"
|
|
310
|
+
)
|
|
311
|
+
value = jax.random.wrap_key_data(value, impl=impl)
|
|
312
|
+
if target is not None:
|
|
313
|
+
value = jax.device_put(value, target.values[index].sharding)
|
|
314
|
+
values.append(value)
|
|
315
|
+
restored[name] = State(tuple(values), schema)
|
|
316
|
+
if set(decoded) != expected_names:
|
|
317
|
+
raise ValueError("Checkpoint array payload does not match its metadata")
|
|
318
|
+
if targets is not None:
|
|
319
|
+
unknown = set(targets) - set(restored)
|
|
320
|
+
if unknown:
|
|
321
|
+
raise KeyError(
|
|
322
|
+
f"Checkpoint is missing target groups: {sorted(unknown)}"
|
|
323
|
+
)
|
|
324
|
+
bundle_metadata = metadata.get("metadata", {})
|
|
325
|
+
dataset_cursor = metadata.get("dataset_cursor", {})
|
|
326
|
+
if not isinstance(bundle_metadata, Mapping) or not isinstance(
|
|
327
|
+
dataset_cursor, Mapping
|
|
328
|
+
):
|
|
329
|
+
raise ValueError("Checkpoint bundle metadata must be objects")
|
|
330
|
+
return CheckpointRestore(
|
|
331
|
+
CheckpointBundle(restored, bundle_metadata, dataset_cursor), reference
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
def _reference(self, checkpoint: CheckpointRef | str) -> CheckpointRef:
|
|
335
|
+
if isinstance(checkpoint, CheckpointRef):
|
|
336
|
+
return checkpoint
|
|
337
|
+
key = normalize_storage_key(checkpoint)
|
|
338
|
+
record = _json_object(self.store.get(key), "checkpoint commit")
|
|
339
|
+
if record.get("format") != _FORMAT:
|
|
340
|
+
raise ValueError("Unsupported checkpoint commit")
|
|
341
|
+
return CheckpointRef(int(record["step"]), str(record["generation"]), key)
|
|
342
|
+
|
|
343
|
+
def _prune(self) -> None:
|
|
344
|
+
if self.max_to_keep is None:
|
|
345
|
+
return
|
|
346
|
+
expired = self.checkpoints()[: -self.max_to_keep]
|
|
347
|
+
for reference in expired:
|
|
348
|
+
for key in self.store.list(f"generations/{reference.generation}/"):
|
|
349
|
+
self.store.delete(key)
|
|
350
|
+
self.store.delete(reference.commit_key)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Byte-oriented checkpoint storage and its durable POSIX implementation."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import uuid
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path, PurePosixPath
|
|
7
|
+
from typing import Protocol, runtime_checkable
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@runtime_checkable
|
|
11
|
+
class ByteStore(Protocol):
|
|
12
|
+
"""Dependency-free immutable-key checkpoint storage boundary."""
|
|
13
|
+
|
|
14
|
+
def get(self, key: str) -> bytes: ...
|
|
15
|
+
def put_if_absent(self, key: str, value: bytes) -> bool: ...
|
|
16
|
+
def exists(self, key: str) -> bool: ...
|
|
17
|
+
def list(self, prefix: str = "") -> tuple[str, ...]: ...
|
|
18
|
+
def delete(self, key: str) -> None: ...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def normalize_storage_key(key: str, *, allow_empty: bool = False) -> str:
|
|
22
|
+
"""Validate and return a normalized relative POSIX storage key."""
|
|
23
|
+
|
|
24
|
+
if not isinstance(key, str) or "\\" in key or "\x00" in key:
|
|
25
|
+
raise ValueError("Storage keys must be normalized POSIX strings")
|
|
26
|
+
if allow_empty and key == "":
|
|
27
|
+
return ""
|
|
28
|
+
path = PurePosixPath(key)
|
|
29
|
+
normalized = path.as_posix()
|
|
30
|
+
if (
|
|
31
|
+
path.is_absolute()
|
|
32
|
+
or ".." in path.parts
|
|
33
|
+
or normalized != key
|
|
34
|
+
or (not allow_empty and normalized in {"", "."})
|
|
35
|
+
):
|
|
36
|
+
raise ValueError("Storage keys must be normalized and relative")
|
|
37
|
+
return "" if normalized == "." else normalized
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def fsync_directory(path: Path) -> None:
|
|
41
|
+
descriptor = os.open(path, os.O_RDONLY)
|
|
42
|
+
try:
|
|
43
|
+
os.fsync(descriptor)
|
|
44
|
+
finally:
|
|
45
|
+
os.close(descriptor)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class PosixCheckpointStore:
|
|
50
|
+
"""Durable shared-filesystem implementation of :class:`ByteStore`."""
|
|
51
|
+
|
|
52
|
+
root: Path
|
|
53
|
+
|
|
54
|
+
def __post_init__(self) -> None:
|
|
55
|
+
object.__setattr__(self, "root", Path(self.root))
|
|
56
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
|
|
58
|
+
def path(self, key: str) -> Path:
|
|
59
|
+
candidate = self.root / normalize_storage_key(key)
|
|
60
|
+
try:
|
|
61
|
+
candidate.resolve(strict=False).relative_to(self.root.resolve())
|
|
62
|
+
except ValueError as error:
|
|
63
|
+
raise ValueError("Storage key escapes the configured root") from error
|
|
64
|
+
return candidate
|
|
65
|
+
|
|
66
|
+
def get(self, key: str) -> bytes:
|
|
67
|
+
return self.path(key).read_bytes()
|
|
68
|
+
|
|
69
|
+
def put_if_absent(self, key: str, value: bytes) -> bool:
|
|
70
|
+
path = self.path(key)
|
|
71
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
temporary = path.parent / f".{path.name}.{uuid.uuid4().hex}.writing"
|
|
73
|
+
try:
|
|
74
|
+
with temporary.open("xb") as file:
|
|
75
|
+
file.write(value)
|
|
76
|
+
file.flush()
|
|
77
|
+
os.fsync(file.fileno())
|
|
78
|
+
try:
|
|
79
|
+
os.link(temporary, path)
|
|
80
|
+
except FileExistsError:
|
|
81
|
+
return False
|
|
82
|
+
fsync_directory(path.parent)
|
|
83
|
+
return True
|
|
84
|
+
finally:
|
|
85
|
+
temporary.unlink(missing_ok=True)
|
|
86
|
+
|
|
87
|
+
def list(self, prefix: str = "") -> tuple[str, ...]:
|
|
88
|
+
trailing_separator = prefix.endswith("/")
|
|
89
|
+
candidate = prefix[:-1] if trailing_separator else prefix
|
|
90
|
+
normalized = normalize_storage_key(candidate, allow_empty=True)
|
|
91
|
+
if trailing_separator and normalized:
|
|
92
|
+
normalized += "/"
|
|
93
|
+
return tuple(
|
|
94
|
+
sorted(
|
|
95
|
+
path.relative_to(self.root).as_posix()
|
|
96
|
+
for path in self.root.rglob("*")
|
|
97
|
+
if path.is_file()
|
|
98
|
+
and not path.name.endswith(".writing")
|
|
99
|
+
and path.relative_to(self.root).as_posix().startswith(normalized)
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def delete(self, key: str) -> None:
|
|
104
|
+
self.path(key).unlink(missing_ok=True)
|
|
105
|
+
|
|
106
|
+
def exists(self, key: str) -> bool:
|
|
107
|
+
return self.path(key).is_file()
|
singularic/core/apply.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""The explicit graph/state execution boundary for Singularic programs."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .context import execution_context
|
|
7
|
+
from .module import GraphDef, _apply_graph
|
|
8
|
+
from .precision import NumericsPlan, PrecisionPolicy, precision_scope
|
|
9
|
+
from .rng import RngPlan, rng_scope
|
|
10
|
+
from .state import State
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class ApplyContext:
|
|
15
|
+
"""Execution inputs that used to be hidden in ambient framework state.
|
|
16
|
+
|
|
17
|
+
``rng_plan`` and ``rng_state`` must either be supplied together or omitted.
|
|
18
|
+
Callers can use ordinary JAX transformations over :func:`apply` because the
|
|
19
|
+
complete mutable program state remains an explicit argument/result.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
precision: PrecisionPolicy | NumericsPlan | None = None
|
|
23
|
+
rng_plan: RngPlan | None = None
|
|
24
|
+
rng_state: State | None = None
|
|
25
|
+
|
|
26
|
+
def __post_init__(self) -> None:
|
|
27
|
+
if (self.rng_plan is None) != (self.rng_state is None):
|
|
28
|
+
raise ValueError("ApplyContext requires both rng_plan and rng_state")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def apply(
|
|
32
|
+
graph: GraphDef,
|
|
33
|
+
state: State,
|
|
34
|
+
*args: Any,
|
|
35
|
+
context: ApplyContext | None = None,
|
|
36
|
+
method: str = "__call__",
|
|
37
|
+
**kwargs: Any,
|
|
38
|
+
) -> tuple[Any, State]:
|
|
39
|
+
"""Execute one graph method with all framework policy inputs explicit."""
|
|
40
|
+
|
|
41
|
+
resolved_context = ApplyContext() if context is None else context
|
|
42
|
+
precision = resolved_context.precision
|
|
43
|
+
numerics = (
|
|
44
|
+
None
|
|
45
|
+
if precision is None
|
|
46
|
+
else precision
|
|
47
|
+
if isinstance(precision, NumericsPlan)
|
|
48
|
+
else precision.compile(state.schema)
|
|
49
|
+
)
|
|
50
|
+
with execution_context():
|
|
51
|
+
if numerics is None:
|
|
52
|
+
if resolved_context.rng_plan is None:
|
|
53
|
+
return _apply_graph(graph, state, *args, method=method, **kwargs)
|
|
54
|
+
assert resolved_context.rng_state is not None
|
|
55
|
+
with rng_scope(resolved_context.rng_plan, resolved_context.rng_state):
|
|
56
|
+
return _apply_graph(graph, state, *args, method=method, **kwargs)
|
|
57
|
+
with precision_scope(numerics):
|
|
58
|
+
if resolved_context.rng_plan is None:
|
|
59
|
+
return _apply_graph(graph, state, *args, method=method, **kwargs)
|
|
60
|
+
assert resolved_context.rng_state is not None
|
|
61
|
+
with rng_scope(resolved_context.rng_plan, resolved_context.rng_state):
|
|
62
|
+
return _apply_graph(graph, state, *args, method=method, **kwargs)
|