weftcodec 1.2.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.
- weftcodec/__init__.py +62 -0
- weftcodec/__main__.py +6 -0
- weftcodec/ai_modes.py +3360 -0
- weftcodec/cli.py +252 -0
- weftcodec/crc32c.py +33 -0
- weftcodec/dcp.py +2108 -0
- weftcodec/dependency.py +224 -0
- weftcodec/errors.py +119 -0
- weftcodec/frame.py +455 -0
- weftcodec/hashing.py +56 -0
- weftcodec/huffman.py +212 -0
- weftcodec/identity.py +141 -0
- weftcodec/limits.py +58 -0
- weftcodec/modes.py +718 -0
- weftcodec/orbax.py +1734 -0
- weftcodec/service.py +959 -0
- weftcodec/torch.py +94 -0
- weftcodec/varint.py +88 -0
- weftcodec/wcb.py +310 -0
- weftcodec-1.2.0.dist-info/METADATA +257 -0
- weftcodec-1.2.0.dist-info/RECORD +24 -0
- weftcodec-1.2.0.dist-info/WHEEL +5 -0
- weftcodec-1.2.0.dist-info/entry_points.txt +2 -0
- weftcodec-1.2.0.dist-info/top_level.txt +1 -0
weftcodec/orbax.py
ADDED
|
@@ -0,0 +1,1734 @@
|
|
|
1
|
+
"""JAX / Orbax checkpoint adapter over WeftFS.
|
|
2
|
+
|
|
3
|
+
``weftcodec.orbax`` provides an Orbax ``CheckpointHandler`` that stores a JAX
|
|
4
|
+
PyTree as one canonical ``weft.checkpoint.v1`` checkpoint through the shipped
|
|
5
|
+
Rust CLI. It reuses the storage plumbing of :mod:`weftcodec.dcp` and does not
|
|
6
|
+
introduce a wire format: leaves are written into a safetensors staging
|
|
7
|
+
container, the Rust CLI ingests that container, and a canonical WCB record
|
|
8
|
+
(``weft.orbax.v1``) binds each flattened leaf path to its dtype, shape,
|
|
9
|
+
content root, and logical root.
|
|
10
|
+
|
|
11
|
+
The "no silent fallback" rules of the DCP adapter apply unchanged:
|
|
12
|
+
|
|
13
|
+
* a missing CLI, store, tensor, or metadata record is a typed error;
|
|
14
|
+
* every restored leaf is checked against the recorded BLAKE3 content root,
|
|
15
|
+
dtype, shape, and the checkpoint manifest;
|
|
16
|
+
* a target PyTree with a missing, extra, or differently typed leaf fails
|
|
17
|
+
closed instead of guessing;
|
|
18
|
+
* staging is bounded (``staging_bytes``) and never silently exceeded.
|
|
19
|
+
|
|
20
|
+
Orbax and JAX are optional. Importing :mod:`weftcodec.orbax` without them works
|
|
21
|
+
and exposes the same classes/methods (contract mode); operations that need
|
|
22
|
+
JAX raise :class:`JaxRequired`.
|
|
23
|
+
|
|
24
|
+
Not claimed: GPU/TPU I/O, multi-process or multi-host JAX, a full ``orbax``
|
|
25
|
+
replacement, TensorStore/OCDBT compatibility, or a zero-copy path. The adapter
|
|
26
|
+
copies each leaf through host memory, one leaf at a time, bounded by
|
|
27
|
+
``staging_bytes``.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import dataclasses
|
|
33
|
+
import importlib
|
|
34
|
+
import os
|
|
35
|
+
import shutil
|
|
36
|
+
import struct
|
|
37
|
+
import tempfile
|
|
38
|
+
import threading
|
|
39
|
+
import time
|
|
40
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
|
|
43
|
+
from . import wcb
|
|
44
|
+
from .dcp import (
|
|
45
|
+
DEFAULT_CLI,
|
|
46
|
+
DEFAULT_STAGING_BYTES,
|
|
47
|
+
DEFAULT_TIMEOUT_SECONDS,
|
|
48
|
+
DcpError,
|
|
49
|
+
ExactnessError,
|
|
50
|
+
InvalidCheckpointId,
|
|
51
|
+
InvalidMetadata,
|
|
52
|
+
MetadataMissing,
|
|
53
|
+
SaveFailed,
|
|
54
|
+
StagingLimitExceeded,
|
|
55
|
+
StoreCommandError,
|
|
56
|
+
StoreUnavailable,
|
|
57
|
+
AsyncSaveHandle,
|
|
58
|
+
_Storage,
|
|
59
|
+
_generation,
|
|
60
|
+
_parse_safetensors,
|
|
61
|
+
_parse_topology,
|
|
62
|
+
_validate_checkpoint_id,
|
|
63
|
+
init_store,
|
|
64
|
+
verify_checkpoint,
|
|
65
|
+
)
|
|
66
|
+
from .hashing import content_root
|
|
67
|
+
from .service import CheckpointService, ServiceError
|
|
68
|
+
from .dcp import _default_cli
|
|
69
|
+
|
|
70
|
+
__all__ = [
|
|
71
|
+
"DEFAULT_STAGING_BYTES",
|
|
72
|
+
"JAX_AVAILABLE",
|
|
73
|
+
"JAX_VERSION",
|
|
74
|
+
"ORBAX_AVAILABLE",
|
|
75
|
+
"ORBAX_METADATA_NAME",
|
|
76
|
+
"ORBAX_METADATA_SCHEMA",
|
|
77
|
+
"ORBAX_POINTER_NAME",
|
|
78
|
+
"ORBAX_VERSION",
|
|
79
|
+
"OrbaxAdapterError",
|
|
80
|
+
"OrbaxLeaf",
|
|
81
|
+
"OrbaxMetadata",
|
|
82
|
+
"OrbaxSaveResult",
|
|
83
|
+
"DuplicateLeafPath",
|
|
84
|
+
"JaxRequired",
|
|
85
|
+
"MismatchedLeaf",
|
|
86
|
+
"MissingLeaf",
|
|
87
|
+
"UnexpectedLeaf",
|
|
88
|
+
"UnsupportedLeaf",
|
|
89
|
+
"UnsupportedDtype",
|
|
90
|
+
"WeftOrbaxHandler",
|
|
91
|
+
"WeftSaveArgs",
|
|
92
|
+
"WeftRestoreArgs",
|
|
93
|
+
"flatten_pytree",
|
|
94
|
+
"leaf_paths",
|
|
95
|
+
"metadata_of",
|
|
96
|
+
"restore_pytree",
|
|
97
|
+
"save_async_pytree",
|
|
98
|
+
"save_pytree",
|
|
99
|
+
"verify_checkpoint",
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
ORBAX_METADATA_SCHEMA = "weft.orbax.v1"
|
|
103
|
+
ORBAX_POINTER_SCHEMA = "weft.orbax.pointer.v1"
|
|
104
|
+
ORBAX_METADATA_NAME = "orbax.metadata"
|
|
105
|
+
ORBAX_POINTER_NAME = "weft.orbax.wcb"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
# Optional JAX / Orbax. Importing weftcodec never imports these modules.
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _load_jax():
|
|
113
|
+
try:
|
|
114
|
+
return importlib.import_module("jax")
|
|
115
|
+
except Exception: # ImportError or a broken optional install
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
_JAX = _load_jax()
|
|
120
|
+
JAX_AVAILABLE = _JAX is not None
|
|
121
|
+
JAX_VERSION = getattr(_JAX, "__version__", None) if _JAX is not None else None
|
|
122
|
+
|
|
123
|
+
_NP = None
|
|
124
|
+
_ML_DTYPES = None
|
|
125
|
+
if JAX_AVAILABLE: # pragma: no branch - both paths are exercised by the court
|
|
126
|
+
try:
|
|
127
|
+
import numpy as _NP # noqa: F401
|
|
128
|
+
import ml_dtypes as _ML_DTYPES # noqa: F401
|
|
129
|
+
_OPTIONAL_IMPORT_ERROR = None
|
|
130
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
131
|
+
_JAX = None
|
|
132
|
+
JAX_AVAILABLE = False
|
|
133
|
+
JAX_VERSION = None
|
|
134
|
+
_OPTIONAL_IMPORT_ERROR = str(exc)
|
|
135
|
+
else:
|
|
136
|
+
_OPTIONAL_IMPORT_ERROR = "jax is not installed"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _load_orbax():
|
|
140
|
+
try:
|
|
141
|
+
return importlib.import_module("orbax.checkpoint")
|
|
142
|
+
except Exception:
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
_ORBAX = _load_orbax()
|
|
147
|
+
ORBAX_AVAILABLE = _ORBAX is not None
|
|
148
|
+
ORBAX_VERSION = getattr(_ORBAX, "__version__", None) if _ORBAX is not None else None
|
|
149
|
+
|
|
150
|
+
if ORBAX_AVAILABLE:
|
|
151
|
+
try:
|
|
152
|
+
from orbax.checkpoint import AsyncCheckpointHandler as _AsyncCheckpointHandler
|
|
153
|
+
from orbax.checkpoint import CheckpointHandler as _CheckpointHandler
|
|
154
|
+
from orbax.checkpoint import args as _orbax_args
|
|
155
|
+
|
|
156
|
+
_ORBAX_IMPORT_ERROR = None
|
|
157
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
158
|
+
ORBAX_AVAILABLE = False
|
|
159
|
+
ORBAX_VERSION = None
|
|
160
|
+
_ORBAX = None
|
|
161
|
+
_ORBAX_IMPORT_ERROR = str(exc)
|
|
162
|
+
else: # pragma: no cover - the court always has orbax
|
|
163
|
+
_ORBAX_IMPORT_ERROR = "orbax.checkpoint is not installed"
|
|
164
|
+
|
|
165
|
+
if not ORBAX_AVAILABLE:
|
|
166
|
+
|
|
167
|
+
class _CheckpointHandler: # type: ignore[no-redef]
|
|
168
|
+
"""Contract-mode stand-in when Orbax is absent."""
|
|
169
|
+
|
|
170
|
+
class _AsyncCheckpointHandler(_CheckpointHandler): # type: ignore[no-redef]
|
|
171
|
+
"""Contract-mode stand-in when Orbax is absent."""
|
|
172
|
+
|
|
173
|
+
class _CheckpointArgs: # type: ignore[no-redef]
|
|
174
|
+
"""Contract-mode stand-in for ``orbax.checkpoint.args.CheckpointArgs``."""
|
|
175
|
+
|
|
176
|
+
else:
|
|
177
|
+
_CheckpointArgs = _orbax_args.CheckpointArgs
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _require_jax(operation):
|
|
181
|
+
if not JAX_AVAILABLE:
|
|
182
|
+
raise JaxRequired(
|
|
183
|
+
"%s requires JAX (install with `pip install jax[cpu] orbax-checkpoint`); "
|
|
184
|
+
"weftcodec.orbax imported in contract mode: %s"
|
|
185
|
+
% (operation, _OPTIONAL_IMPORT_ERROR)
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _orbax_version():
|
|
190
|
+
return ORBAX_VERSION or "unavailable"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# ---------------------------------------------------------------------------
|
|
194
|
+
# Typed errors
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class OrbaxAdapterError(DcpError):
|
|
198
|
+
"""Base class for Orbax-adapter-specific failures."""
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class JaxRequired(OrbaxAdapterError):
|
|
202
|
+
"""This operation needs JAX but it is not installed."""
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class UnsupportedLeaf(OrbaxAdapterError):
|
|
206
|
+
"""A PyTree leaf is not an array with an exact WEFT-TENSOR v1 dtype."""
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class UnsupportedDtype(OrbaxAdapterError):
|
|
210
|
+
"""A leaf dtype has no exact safetensors/WEFT-TENSOR v1 mapping."""
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class DuplicateLeafPath(OrbaxAdapterError):
|
|
214
|
+
"""Two flattened leaves produced the same canonical path."""
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class MissingLeaf(OrbaxAdapterError):
|
|
218
|
+
"""A stored leaf is absent from the restore target PyTree."""
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class UnexpectedLeaf(OrbaxAdapterError):
|
|
222
|
+
"""The restore target has a leaf that is not in the checkpoint."""
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class MismatchedLeaf(OrbaxAdapterError):
|
|
226
|
+
"""A target leaf dtype or shape disagrees with the stored leaf."""
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ---------------------------------------------------------------------------
|
|
230
|
+
# Canonical leaf identity and WCB records
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _u(value):
|
|
234
|
+
return wcb.U64(int(value))
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _i(value):
|
|
238
|
+
if isinstance(value, wcb.U64):
|
|
239
|
+
return int(value.value)
|
|
240
|
+
if isinstance(value, wcb.I64):
|
|
241
|
+
return int(value.value)
|
|
242
|
+
return int(value)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _u_list(values):
|
|
246
|
+
return [_u(value) for value in values]
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _i_list(values):
|
|
250
|
+
return tuple(_i(value) for value in values)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
#: canonical WEFT-TENSOR dtype -> (safetensors spelling, element bytes, numpy
|
|
254
|
+
#: dtype factory name). Only these dtypes have an exact Rust mapping.
|
|
255
|
+
_DTYPE_TABLE = {
|
|
256
|
+
"f16": ("F16", 2, "float16"),
|
|
257
|
+
"bf16": ("BF16", 2, "bfloat16"),
|
|
258
|
+
"f32": ("F32", 4, "float32"),
|
|
259
|
+
"f64": ("F64", 8, "float64"),
|
|
260
|
+
"i8": ("I8", 1, "int8"),
|
|
261
|
+
"u8": ("U8", 1, "uint8"),
|
|
262
|
+
"i16": ("I16", 2, "int16"),
|
|
263
|
+
"i32": ("I32", 4, "int32"),
|
|
264
|
+
"i64": ("I64", 8, "int64"),
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
#: JAX/numpy dtype spelling -> canonical WEFT-TENSOR dtype.
|
|
268
|
+
_CANONICAL_BY_NAME = {
|
|
269
|
+
"float16": "f16",
|
|
270
|
+
"bfloat16": "bf16",
|
|
271
|
+
"float32": "f32",
|
|
272
|
+
"float64": "f64",
|
|
273
|
+
"int8": "i8",
|
|
274
|
+
"uint8": "u8",
|
|
275
|
+
"int16": "i16",
|
|
276
|
+
"int32": "i32",
|
|
277
|
+
"int64": "i64",
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _dtype_name(dtype) -> str:
|
|
282
|
+
"""Normalise a JAX/numpy dtype to its short spelling."""
|
|
283
|
+
return str(dtype)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _canonical_dtype(dtype) -> str:
|
|
287
|
+
name = _dtype_name(dtype)
|
|
288
|
+
try:
|
|
289
|
+
return _CANONICAL_BY_NAME[name]
|
|
290
|
+
except KeyError:
|
|
291
|
+
raise UnsupportedDtype(
|
|
292
|
+
"dtype %s has no exact WEFT-TENSOR/safetensors v1 mapping; "
|
|
293
|
+
"supported: %s"
|
|
294
|
+
% (name, ", ".join(sorted(_CANONICAL_BY_NAME)))
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _safetensors_dtype(canonical: str) -> str:
|
|
299
|
+
try:
|
|
300
|
+
return _DTYPE_TABLE[canonical][0]
|
|
301
|
+
except KeyError:
|
|
302
|
+
raise UnsupportedDtype("dtype %s has no safetensors mapping" % canonical)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _element_bytes(canonical: str) -> int:
|
|
306
|
+
try:
|
|
307
|
+
return _DTYPE_TABLE[canonical][1]
|
|
308
|
+
except KeyError:
|
|
309
|
+
raise UnsupportedDtype("dtype %s has no element size" % canonical)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _numpy_dtype(canonical: str):
|
|
313
|
+
_require_jax("materialising a restored leaf")
|
|
314
|
+
try:
|
|
315
|
+
name = _DTYPE_TABLE[canonical][2]
|
|
316
|
+
except KeyError:
|
|
317
|
+
raise UnsupportedDtype("dtype %s has no numpy mapping" % canonical)
|
|
318
|
+
if name == "bfloat16":
|
|
319
|
+
if _ML_DTYPES is None: # pragma: no cover - jax always brings ml_dtypes
|
|
320
|
+
raise UnsupportedDtype("bfloat16 needs ml_dtypes")
|
|
321
|
+
return _NP.dtype(_ML_DTYPES.bfloat16)
|
|
322
|
+
return _NP.dtype(name)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _canonical_path(path_tuple) -> str:
|
|
326
|
+
try:
|
|
327
|
+
text = _JAX.tree_util.keystr(path_tuple)
|
|
328
|
+
except Exception: # pragma: no cover - defensive for exotic key types
|
|
329
|
+
text = "".join(repr(key) for key in path_tuple)
|
|
330
|
+
return text or "<root>"
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _path_types(path_tuple):
|
|
334
|
+
return [type(key).__name__ for key in path_tuple]
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _shape_product(shape) -> int:
|
|
338
|
+
product = 1
|
|
339
|
+
for extent in shape:
|
|
340
|
+
extent = int(extent)
|
|
341
|
+
if extent < 0:
|
|
342
|
+
raise OrbaxAdapterError("negative leaf extent %d" % extent)
|
|
343
|
+
product *= extent
|
|
344
|
+
return product
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@dataclasses.dataclass(frozen=True)
|
|
348
|
+
class OrbaxLeaf:
|
|
349
|
+
"""One flattened leaf as recorded in ``weft.orbax.v1``.
|
|
350
|
+
|
|
351
|
+
``element_bytes`` is the total tensor byte length, matching the manifest's
|
|
352
|
+
``element_bytes`` field (not the per-element width).
|
|
353
|
+
"""
|
|
354
|
+
|
|
355
|
+
entry: str
|
|
356
|
+
path: str
|
|
357
|
+
path_types: tuple
|
|
358
|
+
dtype: str
|
|
359
|
+
shape: tuple
|
|
360
|
+
element_bytes: int
|
|
361
|
+
content_root: bytes
|
|
362
|
+
logical_root: bytes
|
|
363
|
+
|
|
364
|
+
def to_value(self):
|
|
365
|
+
return {
|
|
366
|
+
"entry": self.entry,
|
|
367
|
+
"path": self.path,
|
|
368
|
+
"path_types": list(self.path_types),
|
|
369
|
+
"dtype": self.dtype,
|
|
370
|
+
"shape": _u_list(self.shape),
|
|
371
|
+
"element_bytes": _u(self.element_bytes),
|
|
372
|
+
"content_root": self.content_root,
|
|
373
|
+
"logical_root": self.logical_root,
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
@classmethod
|
|
377
|
+
def from_value(cls, value):
|
|
378
|
+
try:
|
|
379
|
+
shape = _i_list(value["shape"])
|
|
380
|
+
element_bytes = _i(value["element_bytes"])
|
|
381
|
+
content = bytes(value["content_root"])
|
|
382
|
+
logical = bytes(value["logical_root"])
|
|
383
|
+
dtype = value["dtype"]
|
|
384
|
+
entry = value["entry"]
|
|
385
|
+
path = value["path"]
|
|
386
|
+
path_types = tuple(str(item) for item in value["path_types"])
|
|
387
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
388
|
+
raise InvalidMetadata("bad orbax leaf record: %s" % exc) from exc
|
|
389
|
+
if dtype not in _DTYPE_TABLE:
|
|
390
|
+
raise InvalidMetadata("orbax leaf %r has unknown dtype %r" % (path, dtype))
|
|
391
|
+
expected = _shape_product(shape) * _element_bytes(dtype)
|
|
392
|
+
if element_bytes != expected:
|
|
393
|
+
raise InvalidMetadata(
|
|
394
|
+
"orbax leaf %r element_bytes %d != %d (shape * width) for dtype %s"
|
|
395
|
+
% (path, element_bytes, expected, dtype)
|
|
396
|
+
)
|
|
397
|
+
if len(content) != 32 or len(logical) != 32:
|
|
398
|
+
raise InvalidMetadata("orbax leaf %r roots must be 32 bytes" % path)
|
|
399
|
+
if not entry or not path:
|
|
400
|
+
raise InvalidMetadata("orbax leaf entry/path must be non-empty")
|
|
401
|
+
return cls(
|
|
402
|
+
entry=entry,
|
|
403
|
+
path=path,
|
|
404
|
+
path_types=path_types,
|
|
405
|
+
dtype=dtype,
|
|
406
|
+
shape=shape,
|
|
407
|
+
element_bytes=element_bytes,
|
|
408
|
+
content_root=content,
|
|
409
|
+
logical_root=logical,
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
@dataclasses.dataclass(frozen=True)
|
|
414
|
+
class OrbaxMetadata:
|
|
415
|
+
"""Decoded ``weft.orbax.v1`` metadata record."""
|
|
416
|
+
|
|
417
|
+
checkpoint_id: str
|
|
418
|
+
storage_name: str
|
|
419
|
+
leaves: tuple
|
|
420
|
+
write_topology: tuple
|
|
421
|
+
shard_strategy: str
|
|
422
|
+
staging_bytes: int
|
|
423
|
+
source_file_bytes: int
|
|
424
|
+
source_sha256: str
|
|
425
|
+
jax_version: str
|
|
426
|
+
orbax_version: str
|
|
427
|
+
|
|
428
|
+
@property
|
|
429
|
+
def leaf_count(self):
|
|
430
|
+
return len(self.leaves)
|
|
431
|
+
|
|
432
|
+
@property
|
|
433
|
+
def total_element_bytes(self):
|
|
434
|
+
return sum(leaf.element_bytes for leaf in self.leaves)
|
|
435
|
+
|
|
436
|
+
def by_path(self):
|
|
437
|
+
return {leaf.path: leaf for leaf in self.leaves}
|
|
438
|
+
|
|
439
|
+
def by_entry(self):
|
|
440
|
+
return {leaf.entry: leaf for leaf in self.leaves}
|
|
441
|
+
|
|
442
|
+
def to_value(self):
|
|
443
|
+
return {
|
|
444
|
+
"schema": ORBAX_METADATA_SCHEMA,
|
|
445
|
+
"checkpoint_id": self.checkpoint_id,
|
|
446
|
+
"storage_name": self.storage_name,
|
|
447
|
+
"leaf_count": _u(self.leaf_count),
|
|
448
|
+
"leaves": [leaf.to_value() for leaf in self.leaves],
|
|
449
|
+
"write_topology": _u_list(self.write_topology),
|
|
450
|
+
"shard_strategy": self.shard_strategy,
|
|
451
|
+
"staging_bytes": _u(self.staging_bytes),
|
|
452
|
+
"source_file_bytes": _u(self.source_file_bytes),
|
|
453
|
+
"source_sha256": self.source_sha256,
|
|
454
|
+
"jax_version": self.jax_version,
|
|
455
|
+
"orbax_version": self.orbax_version,
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def serialize_metadata(metadata: OrbaxMetadata) -> bytes:
|
|
460
|
+
return wcb.encode(metadata.to_value())
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
_METADATA_KEYS = {
|
|
464
|
+
"schema",
|
|
465
|
+
"checkpoint_id",
|
|
466
|
+
"storage_name",
|
|
467
|
+
"leaf_count",
|
|
468
|
+
"leaves",
|
|
469
|
+
"write_topology",
|
|
470
|
+
"shard_strategy",
|
|
471
|
+
"staging_bytes",
|
|
472
|
+
"source_file_bytes",
|
|
473
|
+
"source_sha256",
|
|
474
|
+
"jax_version",
|
|
475
|
+
"orbax_version",
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def metadata_from_value(value) -> OrbaxMetadata:
|
|
480
|
+
if not isinstance(value, dict) or value.get("schema") != ORBAX_METADATA_SCHEMA:
|
|
481
|
+
raise InvalidMetadata(
|
|
482
|
+
"orbax metadata schema is %r, expected %r"
|
|
483
|
+
% (
|
|
484
|
+
value.get("schema") if isinstance(value, dict) else None,
|
|
485
|
+
ORBAX_METADATA_SCHEMA,
|
|
486
|
+
)
|
|
487
|
+
)
|
|
488
|
+
if set(value) != _METADATA_KEYS:
|
|
489
|
+
raise InvalidMetadata(
|
|
490
|
+
"orbax metadata keys %r != the exact schema key set"
|
|
491
|
+
% sorted(value)
|
|
492
|
+
)
|
|
493
|
+
leaves = tuple(OrbaxLeaf.from_value(row) for row in value["leaves"])
|
|
494
|
+
if _i(value["leaf_count"]) != len(leaves):
|
|
495
|
+
raise InvalidMetadata(
|
|
496
|
+
"orbax metadata leaf_count %d != %d leaf records"
|
|
497
|
+
% (_i(value["leaf_count"]), len(leaves))
|
|
498
|
+
)
|
|
499
|
+
if len({leaf.entry for leaf in leaves}) != len(leaves):
|
|
500
|
+
raise InvalidMetadata("orbax metadata has duplicate entry names")
|
|
501
|
+
if len({leaf.path for leaf in leaves}) != len(leaves):
|
|
502
|
+
raise InvalidMetadata("orbax metadata has duplicate leaf paths")
|
|
503
|
+
topology = _i_list(value["write_topology"])
|
|
504
|
+
if len(topology) != 2:
|
|
505
|
+
raise InvalidMetadata("write_topology must be [shards, axis]")
|
|
506
|
+
shards, axis = topology
|
|
507
|
+
if shards < 1 or axis < 0:
|
|
508
|
+
raise InvalidMetadata("invalid write_topology %r" % (topology,))
|
|
509
|
+
source_sha = value["source_sha256"]
|
|
510
|
+
if not isinstance(source_sha, str) or len(source_sha) != 64:
|
|
511
|
+
raise InvalidMetadata("source_sha256 must be 64 hex characters")
|
|
512
|
+
try:
|
|
513
|
+
bytes.fromhex(source_sha)
|
|
514
|
+
except ValueError as exc:
|
|
515
|
+
raise InvalidMetadata("source_sha256 is not hex: %s" % exc) from exc
|
|
516
|
+
for key in ("checkpoint_id", "storage_name", "shard_strategy"):
|
|
517
|
+
if not isinstance(value[key], str) or not value[key]:
|
|
518
|
+
raise InvalidMetadata("%s must be a non-empty string" % key)
|
|
519
|
+
return OrbaxMetadata(
|
|
520
|
+
checkpoint_id=value["checkpoint_id"],
|
|
521
|
+
storage_name=value["storage_name"],
|
|
522
|
+
leaves=leaves,
|
|
523
|
+
write_topology=(int(shards), int(axis)),
|
|
524
|
+
shard_strategy=value["shard_strategy"],
|
|
525
|
+
staging_bytes=_i(value["staging_bytes"]),
|
|
526
|
+
source_file_bytes=_i(value["source_file_bytes"]),
|
|
527
|
+
source_sha256=source_sha,
|
|
528
|
+
jax_version=str(value["jax_version"]),
|
|
529
|
+
orbax_version=str(value["orbax_version"]),
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def serialize_pointer(*, store_root, checkpoint_id, storage_name, metadata_bytes):
|
|
534
|
+
value = {
|
|
535
|
+
"schema": ORBAX_POINTER_SCHEMA,
|
|
536
|
+
"store_root": os.path.abspath(os.fspath(store_root)),
|
|
537
|
+
"checkpoint_id": checkpoint_id,
|
|
538
|
+
"storage_name": storage_name,
|
|
539
|
+
"metadata_root": content_root(metadata_bytes),
|
|
540
|
+
}
|
|
541
|
+
return wcb.encode(value)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
_POINTER_KEYS = {
|
|
545
|
+
"schema",
|
|
546
|
+
"store_root",
|
|
547
|
+
"checkpoint_id",
|
|
548
|
+
"storage_name",
|
|
549
|
+
"metadata_root",
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def pointer_from_value(value) -> dict:
|
|
554
|
+
if not isinstance(value, dict) or value.get("schema") != ORBAX_POINTER_SCHEMA:
|
|
555
|
+
raise InvalidMetadata(
|
|
556
|
+
"orbax pointer schema is %r, expected %r"
|
|
557
|
+
% (
|
|
558
|
+
value.get("schema") if isinstance(value, dict) else None,
|
|
559
|
+
ORBAX_POINTER_SCHEMA,
|
|
560
|
+
)
|
|
561
|
+
)
|
|
562
|
+
if set(value) != _POINTER_KEYS:
|
|
563
|
+
raise InvalidMetadata(
|
|
564
|
+
"orbax pointer keys %r != the exact schema key set" % sorted(value)
|
|
565
|
+
)
|
|
566
|
+
root = bytes(value["metadata_root"])
|
|
567
|
+
if len(root) != 32:
|
|
568
|
+
raise InvalidMetadata("orbax pointer metadata_root must be 32 bytes")
|
|
569
|
+
for key in ("store_root", "checkpoint_id", "storage_name"):
|
|
570
|
+
if not isinstance(value[key], str) or not value[key]:
|
|
571
|
+
raise InvalidMetadata("orbax pointer %s must be a non-empty string" % key)
|
|
572
|
+
return {
|
|
573
|
+
"store_root": value["store_root"],
|
|
574
|
+
"checkpoint_id": value["checkpoint_id"],
|
|
575
|
+
"storage_name": value["storage_name"],
|
|
576
|
+
"metadata_root": root,
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
# ---------------------------------------------------------------------------
|
|
581
|
+
# Orbax argument dataclasses (registered with the real handler when available)
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
@dataclasses.dataclass
|
|
585
|
+
class WeftSaveArgs(_CheckpointArgs):
|
|
586
|
+
"""Arguments for one WeftFS/Orbax save.
|
|
587
|
+
|
|
588
|
+
``checkpoint_id`` is the checkpoint name inside the WeftFS store (a single
|
|
589
|
+
store name, not a filesystem path). The Orbax directory passed to the
|
|
590
|
+
handler is a locator: it records the store path and the exact canonical
|
|
591
|
+
metadata record.
|
|
592
|
+
"""
|
|
593
|
+
|
|
594
|
+
item: object = None
|
|
595
|
+
store_root: object = None
|
|
596
|
+
checkpoint_id: object = None
|
|
597
|
+
write_topology: object = (1, 0)
|
|
598
|
+
shard_strategy: str = "zstd"
|
|
599
|
+
staging_bytes: object = None
|
|
600
|
+
overwrite: bool = True
|
|
601
|
+
init_store: bool = False
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
@dataclasses.dataclass
|
|
605
|
+
class WeftRestoreArgs(_CheckpointArgs):
|
|
606
|
+
"""Arguments for one WeftFS/Orbax restore.
|
|
607
|
+
|
|
608
|
+
``item`` is the target PyTree (concrete arrays or ``jax.ShapeDtypeStruct``).
|
|
609
|
+
``restore_topology`` is an optional Rust-side proof layout
|
|
610
|
+
(``n`` or ``(n, axis)``); it is independent of the JAX target sharding.
|
|
611
|
+
"""
|
|
612
|
+
|
|
613
|
+
item: object = None
|
|
614
|
+
store_root: object = None
|
|
615
|
+
checkpoint_id: object = None
|
|
616
|
+
storage_name: object = None
|
|
617
|
+
restore_topology: object = None
|
|
618
|
+
partial_restore: bool = False
|
|
619
|
+
staging_bytes: object = None
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
# Registration happens after the handler class is defined (below).
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
# ---------------------------------------------------------------------------
|
|
626
|
+
# Result objects
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
@dataclasses.dataclass(frozen=True)
|
|
630
|
+
class OrbaxSaveResult:
|
|
631
|
+
"""What one save published."""
|
|
632
|
+
|
|
633
|
+
checkpoint_id: str
|
|
634
|
+
storage_name: str
|
|
635
|
+
store_root: str
|
|
636
|
+
directory: str
|
|
637
|
+
leaves: tuple
|
|
638
|
+
write_topology: tuple
|
|
639
|
+
shard_strategy: str
|
|
640
|
+
staging_bytes: int
|
|
641
|
+
staging_peak_bytes: int
|
|
642
|
+
checkpoint_write_ms: float
|
|
643
|
+
store_command_ms: float
|
|
644
|
+
source_file_bytes: int
|
|
645
|
+
source_sha256: str
|
|
646
|
+
manifest: dict
|
|
647
|
+
metadata: OrbaxMetadata
|
|
648
|
+
|
|
649
|
+
@property
|
|
650
|
+
def leaf_count(self):
|
|
651
|
+
return len(self.leaves)
|
|
652
|
+
|
|
653
|
+
@property
|
|
654
|
+
def leaf_bytes(self):
|
|
655
|
+
return sum(leaf.element_bytes for leaf in self.leaves)
|
|
656
|
+
|
|
657
|
+
def stats(self):
|
|
658
|
+
return {
|
|
659
|
+
"checkpoint_id": self.checkpoint_id,
|
|
660
|
+
"storage_name": self.storage_name,
|
|
661
|
+
"store_root": self.store_root,
|
|
662
|
+
"directory": self.directory,
|
|
663
|
+
"leaf_count": self.leaf_count,
|
|
664
|
+
"leaf_bytes": self.leaf_bytes,
|
|
665
|
+
"staging_bytes": self.staging_bytes,
|
|
666
|
+
"staging_peak_bytes": self.staging_peak_bytes,
|
|
667
|
+
"checkpoint_write_ms": self.checkpoint_write_ms,
|
|
668
|
+
"store_command_ms": self.store_command_ms,
|
|
669
|
+
"source_file_bytes": self.source_file_bytes,
|
|
670
|
+
"source_sha256": self.source_sha256,
|
|
671
|
+
"write_topology": self.write_topology,
|
|
672
|
+
"shard_strategy": self.shard_strategy,
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
# ---------------------------------------------------------------------------
|
|
677
|
+
# Leaf materialisation / target validation
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def _array_bytes(leaf):
|
|
681
|
+
_require_jax("serialising a PyTree leaf")
|
|
682
|
+
array = _NP.asarray(leaf)
|
|
683
|
+
if not array.flags.c_contiguous:
|
|
684
|
+
array = _NP.ascontiguousarray(array)
|
|
685
|
+
if array.dtype.byteorder == ">":
|
|
686
|
+
array = array.byteswap().view(array.dtype.newbyteorder("<"))
|
|
687
|
+
return array.tobytes(order="C")
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _leaf_dtype_shape(leaf):
|
|
691
|
+
_require_jax("inspecting a PyTree leaf")
|
|
692
|
+
if isinstance(leaf, _JAX.ShapeDtypeStruct):
|
|
693
|
+
return _dtype_name(leaf.dtype), tuple(int(x) for x in leaf.shape), leaf
|
|
694
|
+
if isinstance(leaf, _JAX.Array):
|
|
695
|
+
return _dtype_name(leaf.dtype), tuple(int(x) for x in leaf.shape), leaf
|
|
696
|
+
if _NP is not None and isinstance(leaf, _NP.ndarray):
|
|
697
|
+
return _dtype_name(leaf.dtype), tuple(int(x) for x in leaf.shape), leaf
|
|
698
|
+
raise UnsupportedLeaf(
|
|
699
|
+
"PyTree leaf %r is %s; only jax.Array, numpy.ndarray, and "
|
|
700
|
+
"jax.ShapeDtypeStruct are supported"
|
|
701
|
+
% (leaf, type(leaf).__name__)
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _target_sharding(leaf):
|
|
706
|
+
return getattr(leaf, "sharding", None)
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def _to_target(buffer, *, canonical, shape, target):
|
|
710
|
+
"""Materialise one restored leaf from its caller-owned memfd buffer.
|
|
711
|
+
|
|
712
|
+
``buffer`` is the service's :class:`weftcodec.service.TensorBuffer`; the
|
|
713
|
+
dtype/shape were already verified against the metadata, so the mapping is
|
|
714
|
+
used directly. For a JAX destination the host array is handed to
|
|
715
|
+
``device_put`` with no extra host copy; for a numpy destination the caller
|
|
716
|
+
owns the result, so exactly one bounded copy is made.
|
|
717
|
+
"""
|
|
718
|
+
_require_jax("materialising a restored leaf")
|
|
719
|
+
np_dtype = _numpy_dtype(canonical)
|
|
720
|
+
array = _NP.frombuffer(buffer.view, dtype=np_dtype).reshape(tuple(shape))
|
|
721
|
+
if _NP is not None and isinstance(target, _NP.ndarray):
|
|
722
|
+
return _NP.array(array, copy=True, order="C")
|
|
723
|
+
jnp = _JAX.numpy
|
|
724
|
+
value = jnp.asarray(array)
|
|
725
|
+
sharding = _target_sharding(target)
|
|
726
|
+
if sharding is not None:
|
|
727
|
+
value = _JAX.device_put(value, sharding)
|
|
728
|
+
return value
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
# ---------------------------------------------------------------------------
|
|
732
|
+
# Async plumbing
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
class _LazyFuture:
|
|
736
|
+
"""A bounded-worker future that starts work on first ``result()``.
|
|
737
|
+
|
|
738
|
+
Orbax's ``AsyncCheckpointer`` creates its temporary directory as one commit
|
|
739
|
+
operation and then waits on the handler's futures. Writing into that
|
|
740
|
+
directory before the creation operation completes is a real race (the
|
|
741
|
+
creation helper removes and recreates it). Starting the save only when
|
|
742
|
+
``result()`` is called means the directory is ready, while the caller's
|
|
743
|
+
``save()`` still returns immediately.
|
|
744
|
+
"""
|
|
745
|
+
|
|
746
|
+
def __init__(self, executor, function):
|
|
747
|
+
self._executor = executor
|
|
748
|
+
self._function = function
|
|
749
|
+
self._lock = threading.Lock()
|
|
750
|
+
self._future = None
|
|
751
|
+
self._cancelled = False
|
|
752
|
+
|
|
753
|
+
def result(self, timeout=None):
|
|
754
|
+
with self._lock:
|
|
755
|
+
if self._cancelled:
|
|
756
|
+
raise RuntimeError("orbax save future was cancelled")
|
|
757
|
+
if self._future is None:
|
|
758
|
+
self._future = self._executor.submit(self._function)
|
|
759
|
+
future = self._future
|
|
760
|
+
return future.result(timeout=timeout)
|
|
761
|
+
|
|
762
|
+
def cancel(self):
|
|
763
|
+
with self._lock:
|
|
764
|
+
if self._future is not None:
|
|
765
|
+
return self._future.cancel()
|
|
766
|
+
self._cancelled = True
|
|
767
|
+
return True
|
|
768
|
+
|
|
769
|
+
def done(self):
|
|
770
|
+
with self._lock:
|
|
771
|
+
future = self._future
|
|
772
|
+
return future.done() if future is not None else self._cancelled
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
# ---------------------------------------------------------------------------
|
|
776
|
+
# Handler
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
class WeftOrbaxHandler(_AsyncCheckpointHandler):
|
|
780
|
+
"""Orbax ``AsyncCheckpointHandler`` that publishes canonical WeftFS checkpoints.
|
|
781
|
+
|
|
782
|
+
``save``/``restore`` are also usable directly. The real Orbax
|
|
783
|
+
``Checkpointer``, ``AsyncCheckpointer``, and (when the installed Orbax
|
|
784
|
+
supports it) ``CheckpointManager`` can drive this handler through
|
|
785
|
+
:class:`WeftSaveArgs` / :class:`WeftRestoreArgs`.
|
|
786
|
+
|
|
787
|
+
``staging_bytes`` bounds the total leaf bytes of one staging container and
|
|
788
|
+
the total restored bytes materialised for one restore. The executor is
|
|
789
|
+
bounded to ``max_workers`` (1..4); each worker uses the same hard bound.
|
|
790
|
+
"""
|
|
791
|
+
|
|
792
|
+
def __init__(
|
|
793
|
+
self,
|
|
794
|
+
*,
|
|
795
|
+
staging_bytes=DEFAULT_STAGING_BYTES,
|
|
796
|
+
max_workers=1,
|
|
797
|
+
weftfs_cli=None,
|
|
798
|
+
timeout=DEFAULT_TIMEOUT_SECONDS,
|
|
799
|
+
):
|
|
800
|
+
staging_bytes = int(staging_bytes)
|
|
801
|
+
if staging_bytes <= 0:
|
|
802
|
+
raise ValueError("staging_bytes must be a positive byte count")
|
|
803
|
+
workers = int(max_workers)
|
|
804
|
+
if workers < 1 or workers > 4:
|
|
805
|
+
raise ValueError("max_workers must be between 1 and 4")
|
|
806
|
+
self.staging_bytes = staging_bytes
|
|
807
|
+
self.weftfs_cli = weftfs_cli
|
|
808
|
+
self.timeout = float(timeout)
|
|
809
|
+
self._executor = ThreadPoolExecutor(
|
|
810
|
+
max_workers=workers, thread_name_prefix="weft-orbax-save"
|
|
811
|
+
)
|
|
812
|
+
self._service_cli = _default_cli(weftfs_cli)
|
|
813
|
+
self._service = None
|
|
814
|
+
self._service_root = None
|
|
815
|
+
self._lock = threading.RLock()
|
|
816
|
+
self._last_result = None
|
|
817
|
+
self._stats = {
|
|
818
|
+
"saves": 0,
|
|
819
|
+
"restores": 0,
|
|
820
|
+
"leaves_restored": 0,
|
|
821
|
+
"restored_bytes": 0,
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
# -- properties ---------------------------------------------------------
|
|
825
|
+
|
|
826
|
+
def _get_service(self, store_root):
|
|
827
|
+
"""One helper process for every operation of this handler."""
|
|
828
|
+
root = os.path.abspath(os.fspath(store_root))
|
|
829
|
+
if self._service is None or self._service_root != root:
|
|
830
|
+
if self._service is not None:
|
|
831
|
+
self._service.close()
|
|
832
|
+
self._service = CheckpointService(
|
|
833
|
+
root, cli=self._service_cli, timeout=self.timeout
|
|
834
|
+
)
|
|
835
|
+
self._service_root = root
|
|
836
|
+
return self._service
|
|
837
|
+
|
|
838
|
+
@property
|
|
839
|
+
def stats(self):
|
|
840
|
+
with self._lock:
|
|
841
|
+
payload = dict(self._stats)
|
|
842
|
+
result = self.last_result
|
|
843
|
+
if result is not None:
|
|
844
|
+
payload["last_save"] = result.stats()
|
|
845
|
+
payload["staging_bytes"] = self.staging_bytes
|
|
846
|
+
return payload
|
|
847
|
+
|
|
848
|
+
@property
|
|
849
|
+
def last_result(self):
|
|
850
|
+
with self._lock:
|
|
851
|
+
return self._last_result
|
|
852
|
+
|
|
853
|
+
@classmethod
|
|
854
|
+
def typestr(cls):
|
|
855
|
+
return "%s.%s" % (cls.__module__, cls.__qualname__)
|
|
856
|
+
|
|
857
|
+
# -- Orbax interface ----------------------------------------------------
|
|
858
|
+
|
|
859
|
+
def save(self, directory, *args, **kwargs):
|
|
860
|
+
save_args = _extract_args(args, kwargs, WeftSaveArgs, "save")
|
|
861
|
+
return self._save_sync(Path(os.fspath(directory)), save_args)
|
|
862
|
+
|
|
863
|
+
async def async_save(self, directory, *args, **kwargs):
|
|
864
|
+
save_args = _extract_args(args, kwargs, WeftSaveArgs, "async_save")
|
|
865
|
+
if save_args.item is None:
|
|
866
|
+
raise SaveFailed("WeftSaveArgs.item is required")
|
|
867
|
+
return [
|
|
868
|
+
_LazyFuture(
|
|
869
|
+
self._executor,
|
|
870
|
+
lambda: self._save_sync(Path(os.fspath(directory)), save_args),
|
|
871
|
+
)
|
|
872
|
+
]
|
|
873
|
+
|
|
874
|
+
def restore(self, directory, *args, **kwargs):
|
|
875
|
+
restore_args = _extract_args(args, kwargs, WeftRestoreArgs, "restore")
|
|
876
|
+
return self._restore_sync(Path(os.fspath(directory)), restore_args)
|
|
877
|
+
|
|
878
|
+
def metadata(self, directory):
|
|
879
|
+
return read_metadata(Path(os.fspath(directory)))[2]
|
|
880
|
+
|
|
881
|
+
def finalize(self, directory):
|
|
882
|
+
# The store publish and the locator write are already durable; Orbax
|
|
883
|
+
# owns the atomic directory rename/commit of the locator.
|
|
884
|
+
return None
|
|
885
|
+
|
|
886
|
+
def close(self):
|
|
887
|
+
if self._service is not None:
|
|
888
|
+
self._service.close()
|
|
889
|
+
self._service = None
|
|
890
|
+
self._service_root = None
|
|
891
|
+
self._executor.shutdown(wait=True, cancel_futures=False)
|
|
892
|
+
|
|
893
|
+
@staticmethod
|
|
894
|
+
def _translate(exc):
|
|
895
|
+
return _translate_service_error(exc)
|
|
896
|
+
|
|
897
|
+
# -- save ---------------------------------------------------------------
|
|
898
|
+
|
|
899
|
+
def _save_sync(self, directory, save_args):
|
|
900
|
+
_require_jax("writing a PyTree checkpoint")
|
|
901
|
+
if save_args.item is None:
|
|
902
|
+
raise SaveFailed("WeftSaveArgs.item is required")
|
|
903
|
+
store_root = _resolve_save_store_root(save_args.store_root)
|
|
904
|
+
checkpoint_id = _validate_checkpoint_id(save_args.checkpoint_id)
|
|
905
|
+
shards, axis = _parse_topology(save_args.write_topology)
|
|
906
|
+
if save_args.shard_strategy not in ("zstd", "raw", "tensor-sharded"):
|
|
907
|
+
raise ValueError(
|
|
908
|
+
"shard_strategy must be zstd, raw, or tensor-sharded "
|
|
909
|
+
"(tensor-tiled needs a tile shape this adapter does not expose)"
|
|
910
|
+
)
|
|
911
|
+
staging_bound = (
|
|
912
|
+
int(save_args.staging_bytes)
|
|
913
|
+
if save_args.staging_bytes is not None
|
|
914
|
+
else self.staging_bytes
|
|
915
|
+
)
|
|
916
|
+
if staging_bound <= 0:
|
|
917
|
+
raise ValueError("staging_bytes must be a positive byte count")
|
|
918
|
+
|
|
919
|
+
flat = _flatten(save_args.item)
|
|
920
|
+
if not flat:
|
|
921
|
+
raise SaveFailed("refusing to save an empty PyTree")
|
|
922
|
+
entries = _entry_names(len(flat))
|
|
923
|
+
leaves = []
|
|
924
|
+
total = 0
|
|
925
|
+
for index, (path_tuple, leaf) in enumerate(flat):
|
|
926
|
+
path = _canonical_path(path_tuple)
|
|
927
|
+
dtype_name, shape, _source = _leaf_dtype_shape(leaf)
|
|
928
|
+
canonical = _canonical_dtype(dtype_name)
|
|
929
|
+
nbytes = _shape_product(shape) * _element_bytes(canonical)
|
|
930
|
+
total += nbytes
|
|
931
|
+
if total > staging_bound:
|
|
932
|
+
raise StagingLimitExceeded(
|
|
933
|
+
"PyTree leaves need %d staged bytes, above the %d "
|
|
934
|
+
"staging_bytes bound" % (total, staging_bound)
|
|
935
|
+
)
|
|
936
|
+
leaves.append(
|
|
937
|
+
{
|
|
938
|
+
"entry": entries[index],
|
|
939
|
+
"path": path,
|
|
940
|
+
"path_types": tuple(_path_types(path_tuple)),
|
|
941
|
+
"dtype": canonical,
|
|
942
|
+
"shape": tuple(int(x) for x in shape),
|
|
943
|
+
"element_bytes": nbytes,
|
|
944
|
+
"nbytes": nbytes,
|
|
945
|
+
}
|
|
946
|
+
)
|
|
947
|
+
if len({leaf["path"] for leaf in leaves}) != len(leaves):
|
|
948
|
+
raise DuplicateLeafPath(
|
|
949
|
+
"flattened PyTree produced duplicate canonical leaf paths"
|
|
950
|
+
)
|
|
951
|
+
|
|
952
|
+
directory = Path(directory)
|
|
953
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
954
|
+
pointer_path = directory / ORBAX_POINTER_NAME
|
|
955
|
+
peak = 0
|
|
956
|
+
storage_name = None
|
|
957
|
+
store = _Storage(store_root, cli=self.weftfs_cli, timeout=self.timeout)
|
|
958
|
+
if save_args.init_store:
|
|
959
|
+
store.init()
|
|
960
|
+
store.requires_store()
|
|
961
|
+
service = self._get_service(store_root)
|
|
962
|
+
try:
|
|
963
|
+
header = {}
|
|
964
|
+
cursor = 0
|
|
965
|
+
for leaf in leaves:
|
|
966
|
+
header[leaf["entry"]] = {
|
|
967
|
+
"dtype": _safetensors_dtype(leaf["dtype"]),
|
|
968
|
+
"shape": list(leaf["shape"]),
|
|
969
|
+
"data_offsets": [cursor, cursor + leaf["nbytes"]],
|
|
970
|
+
}
|
|
971
|
+
leaf["offset"] = cursor
|
|
972
|
+
cursor += leaf["nbytes"]
|
|
973
|
+
header_raw = _json_bytes(header)
|
|
974
|
+
if len(header_raw) > 64 * 1024 * 1024:
|
|
975
|
+
raise SaveFailed("staging safetensors header exceeds 64 MiB")
|
|
976
|
+
# The container must exist as safetensors bytes (the checkpoint
|
|
977
|
+
# manifest records its header and SHA-256), but it is staged in
|
|
978
|
+
# memory only: no host file, no CLI argument, one memfd hand-off.
|
|
979
|
+
container = bytearray(struct.pack("<Q", len(header_raw)))
|
|
980
|
+
container += header_raw
|
|
981
|
+
for leaf, (path_tuple, source_leaf) in zip(leaves, flat):
|
|
982
|
+
payload = _array_bytes(source_leaf)
|
|
983
|
+
if len(payload) != leaf["nbytes"]:
|
|
984
|
+
raise SaveFailed(
|
|
985
|
+
"leaf %r produced %d bytes, header declared %d"
|
|
986
|
+
% (leaf["path"], len(payload), leaf["nbytes"])
|
|
987
|
+
)
|
|
988
|
+
peak = max(peak, len(payload))
|
|
989
|
+
container += payload
|
|
990
|
+
leaf["content_root"] = content_root(payload)
|
|
991
|
+
|
|
992
|
+
storage_name = _allocate_storage_name(
|
|
993
|
+
store, checkpoint_id, bool(save_args.overwrite)
|
|
994
|
+
)
|
|
995
|
+
started = time.monotonic()
|
|
996
|
+
write_report = service.write_checkpoint(
|
|
997
|
+
storage_name,
|
|
998
|
+
container,
|
|
999
|
+
shards=shards,
|
|
1000
|
+
axis=axis,
|
|
1001
|
+
strategy=save_args.shard_strategy,
|
|
1002
|
+
)
|
|
1003
|
+
checkpoint_write_ms = (time.monotonic() - started) * 1000.0
|
|
1004
|
+
manifest = write_report.manifest.to_value()
|
|
1005
|
+
show = {
|
|
1006
|
+
"manifest_root": write_report.manifest_root,
|
|
1007
|
+
"manifest": manifest,
|
|
1008
|
+
}
|
|
1009
|
+
_verify_manifest(show, leaves, storage_name)
|
|
1010
|
+
|
|
1011
|
+
manifest_rows = {row["name"]: row for row in manifest.get("tensors", [])}
|
|
1012
|
+
recorded = []
|
|
1013
|
+
for index, leaf in enumerate(leaves):
|
|
1014
|
+
row = manifest_rows[leaf["entry"]]
|
|
1015
|
+
recorded.append(
|
|
1016
|
+
OrbaxLeaf(
|
|
1017
|
+
entry=leaf["entry"],
|
|
1018
|
+
path=leaf["path"],
|
|
1019
|
+
path_types=leaf["path_types"],
|
|
1020
|
+
dtype=leaf["dtype"],
|
|
1021
|
+
shape=leaf["shape"],
|
|
1022
|
+
element_bytes=leaf["element_bytes"],
|
|
1023
|
+
content_root=leaf["content_root"],
|
|
1024
|
+
logical_root=bytes.fromhex(row["logical_root"]),
|
|
1025
|
+
)
|
|
1026
|
+
)
|
|
1027
|
+
recorded.sort(key=lambda item: item.path)
|
|
1028
|
+
metadata = OrbaxMetadata(
|
|
1029
|
+
checkpoint_id=checkpoint_id,
|
|
1030
|
+
storage_name=storage_name,
|
|
1031
|
+
leaves=tuple(recorded),
|
|
1032
|
+
write_topology=(int(shards), int(axis)),
|
|
1033
|
+
shard_strategy=save_args.shard_strategy,
|
|
1034
|
+
staging_bytes=staging_bound,
|
|
1035
|
+
source_file_bytes=int(manifest.get("source_file_bytes", 0)),
|
|
1036
|
+
source_sha256=str(manifest.get("source_sha256", "")),
|
|
1037
|
+
jax_version=JAX_VERSION or "unavailable",
|
|
1038
|
+
orbax_version=_orbax_version(),
|
|
1039
|
+
)
|
|
1040
|
+
metadata_bytes = serialize_metadata(metadata)
|
|
1041
|
+
service.write_file(
|
|
1042
|
+
"/checkpoints/%s/%s" % (storage_name, ORBAX_METADATA_NAME),
|
|
1043
|
+
metadata_bytes,
|
|
1044
|
+
)
|
|
1045
|
+
pointer_bytes = serialize_pointer(
|
|
1046
|
+
store_root=store_root,
|
|
1047
|
+
checkpoint_id=checkpoint_id,
|
|
1048
|
+
storage_name=storage_name,
|
|
1049
|
+
metadata_bytes=metadata_bytes,
|
|
1050
|
+
)
|
|
1051
|
+
with open(pointer_path, "wb") as handle:
|
|
1052
|
+
handle.write(pointer_bytes)
|
|
1053
|
+
handle.flush()
|
|
1054
|
+
os.fsync(handle.fileno())
|
|
1055
|
+
_fsync_directory(directory)
|
|
1056
|
+
|
|
1057
|
+
result = OrbaxSaveResult(
|
|
1058
|
+
checkpoint_id=checkpoint_id,
|
|
1059
|
+
storage_name=storage_name,
|
|
1060
|
+
store_root=os.fspath(store_root),
|
|
1061
|
+
directory=os.fspath(directory),
|
|
1062
|
+
leaves=tuple(recorded),
|
|
1063
|
+
write_topology=(int(shards), int(axis)),
|
|
1064
|
+
shard_strategy=save_args.shard_strategy,
|
|
1065
|
+
staging_bytes=staging_bound,
|
|
1066
|
+
staging_peak_bytes=peak,
|
|
1067
|
+
checkpoint_write_ms=checkpoint_write_ms,
|
|
1068
|
+
store_command_ms=store.command_ms,
|
|
1069
|
+
source_file_bytes=int(manifest.get("source_file_bytes", 0)),
|
|
1070
|
+
source_sha256=str(manifest.get("source_sha256", "")),
|
|
1071
|
+
manifest=manifest,
|
|
1072
|
+
metadata=metadata,
|
|
1073
|
+
)
|
|
1074
|
+
with self._lock:
|
|
1075
|
+
self._stats["saves"] += 1
|
|
1076
|
+
self._last_result = result
|
|
1077
|
+
return result
|
|
1078
|
+
finally:
|
|
1079
|
+
pass
|
|
1080
|
+
|
|
1081
|
+
# -- restore ------------------------------------------------------------
|
|
1082
|
+
|
|
1083
|
+
def _restore_sync(self, directory, restore_args):
|
|
1084
|
+
_require_jax("reading a PyTree checkpoint")
|
|
1085
|
+
target = restore_args.item
|
|
1086
|
+
if target is None:
|
|
1087
|
+
raise InvalidMetadata("WeftRestoreArgs.item is required")
|
|
1088
|
+
store, storage_name, metadata, show, _pointer = _resolve_metadata(
|
|
1089
|
+
directory,
|
|
1090
|
+
store_root=restore_args.store_root,
|
|
1091
|
+
checkpoint_id=restore_args.checkpoint_id,
|
|
1092
|
+
storage_name=restore_args.storage_name,
|
|
1093
|
+
weftfs_cli=self.weftfs_cli,
|
|
1094
|
+
timeout=self.timeout,
|
|
1095
|
+
service_factory=self._get_service,
|
|
1096
|
+
)
|
|
1097
|
+
_verify_metadata_manifest(metadata, show)
|
|
1098
|
+
staging_bound = (
|
|
1099
|
+
int(restore_args.staging_bytes)
|
|
1100
|
+
if restore_args.staging_bytes is not None
|
|
1101
|
+
else self.staging_bytes
|
|
1102
|
+
)
|
|
1103
|
+
restore_topology = restore_args.restore_topology
|
|
1104
|
+
shards, axis = (
|
|
1105
|
+
(None, 0)
|
|
1106
|
+
if restore_topology is None
|
|
1107
|
+
else _parse_topology(restore_topology)
|
|
1108
|
+
)
|
|
1109
|
+
|
|
1110
|
+
flat = _flatten(target)
|
|
1111
|
+
target_paths = [_canonical_path(path_tuple) for path_tuple, _ in flat]
|
|
1112
|
+
if len(set(target_paths)) != len(target_paths):
|
|
1113
|
+
raise DuplicateLeafPath("restore target has duplicate canonical paths")
|
|
1114
|
+
stored = metadata.by_path()
|
|
1115
|
+
missing = [path for path in stored if path not in set(target_paths)]
|
|
1116
|
+
extra = [path for path in target_paths if path not in stored]
|
|
1117
|
+
if extra:
|
|
1118
|
+
raise UnexpectedLeaf(
|
|
1119
|
+
"restore target has leaves absent from checkpoint %r: %r"
|
|
1120
|
+
% (storage_name, sorted(extra))
|
|
1121
|
+
)
|
|
1122
|
+
if missing and not restore_args.partial_restore:
|
|
1123
|
+
raise MissingLeaf(
|
|
1124
|
+
"checkpoint %r has leaves absent from the restore target: %r"
|
|
1125
|
+
% (storage_name, sorted(missing))
|
|
1126
|
+
)
|
|
1127
|
+
if not target_paths:
|
|
1128
|
+
raise InvalidMetadata("restore target has no leaves")
|
|
1129
|
+
|
|
1130
|
+
# Fail closed on a target dtype/shape mismatch before reading any data.
|
|
1131
|
+
for (path_tuple, leaf), path in zip(flat, target_paths):
|
|
1132
|
+
dtype_name, shape, _source = _leaf_dtype_shape(leaf)
|
|
1133
|
+
canonical = _canonical_dtype(dtype_name)
|
|
1134
|
+
leaf_meta = stored[path]
|
|
1135
|
+
if canonical != leaf_meta.dtype:
|
|
1136
|
+
raise MismatchedLeaf(
|
|
1137
|
+
"target leaf %r dtype %s != stored dtype %s"
|
|
1138
|
+
% (path, canonical, leaf_meta.dtype)
|
|
1139
|
+
)
|
|
1140
|
+
if tuple(int(x) for x in shape) != leaf_meta.shape:
|
|
1141
|
+
raise MismatchedLeaf(
|
|
1142
|
+
"target leaf %r shape %r != stored shape %r"
|
|
1143
|
+
% (path, tuple(shape), leaf_meta.shape)
|
|
1144
|
+
)
|
|
1145
|
+
|
|
1146
|
+
wanted = [stored[path] for path in target_paths]
|
|
1147
|
+
total_wanted = sum(leaf.element_bytes for leaf in wanted)
|
|
1148
|
+
if total_wanted > staging_bound:
|
|
1149
|
+
raise StagingLimitExceeded(
|
|
1150
|
+
"restore needs %d bytes, above the %d staging_bytes bound"
|
|
1151
|
+
% (total_wanted, staging_bound)
|
|
1152
|
+
)
|
|
1153
|
+
|
|
1154
|
+
all_entries = metadata.by_entry()
|
|
1155
|
+
full = len(wanted) == len(all_entries)
|
|
1156
|
+
buffers = {}
|
|
1157
|
+
try:
|
|
1158
|
+
for leaf in wanted:
|
|
1159
|
+
try:
|
|
1160
|
+
buffer = store.restore_tensor(
|
|
1161
|
+
storage_name,
|
|
1162
|
+
leaf.entry,
|
|
1163
|
+
topology=None if shards is None else (shards, axis),
|
|
1164
|
+
expected_size=int(leaf.element_bytes),
|
|
1165
|
+
)
|
|
1166
|
+
except ServiceError as exc:
|
|
1167
|
+
raise self._translate(exc) from exc
|
|
1168
|
+
if tuple(buffer.shape) != leaf.shape:
|
|
1169
|
+
buffer.close()
|
|
1170
|
+
raise ExactnessError(
|
|
1171
|
+
"leaf %r restored shape %r, expected %r"
|
|
1172
|
+
% (leaf.path, tuple(buffer.shape), leaf.shape)
|
|
1173
|
+
)
|
|
1174
|
+
actual = content_root(buffer.view)
|
|
1175
|
+
if actual != leaf.content_root:
|
|
1176
|
+
buffer.close()
|
|
1177
|
+
raise ExactnessError(
|
|
1178
|
+
"leaf %r restored content root %s != recorded %s"
|
|
1179
|
+
% (leaf.path, actual.hex(), leaf.content_root.hex())
|
|
1180
|
+
)
|
|
1181
|
+
buffers[leaf.entry] = buffer
|
|
1182
|
+
_record_restore_stats(
|
|
1183
|
+
self, len(wanted), sum(buffer.size for buffer in buffers.values())
|
|
1184
|
+
)
|
|
1185
|
+
restored_leaves = []
|
|
1186
|
+
for (path_tuple, target_leaf), path in zip(flat, target_paths):
|
|
1187
|
+
leaf_meta = stored[path]
|
|
1188
|
+
restored_leaves.append(
|
|
1189
|
+
_to_target(
|
|
1190
|
+
buffers[leaf_meta.entry],
|
|
1191
|
+
canonical=leaf_meta.dtype,
|
|
1192
|
+
shape=leaf_meta.shape,
|
|
1193
|
+
target=target_leaf,
|
|
1194
|
+
)
|
|
1195
|
+
)
|
|
1196
|
+
return _JAX.tree_util.tree_unflatten(_treedef(target), restored_leaves)
|
|
1197
|
+
finally:
|
|
1198
|
+
for buffer in buffers.values():
|
|
1199
|
+
buffer.close()
|
|
1200
|
+
|
|
1201
|
+
# -- extension: single-leaf restore -------------------------------------
|
|
1202
|
+
|
|
1203
|
+
def restore_leaf(self, directory, path, *, target=None, **kwargs):
|
|
1204
|
+
"""Restore exactly one recorded leaf by canonical path.
|
|
1205
|
+
|
|
1206
|
+
This is an adapter extension, not part of the Orbax interface. It calls
|
|
1207
|
+
``weftfs checkpoint restore --tensor ENTRY`` for the one leaf and
|
|
1208
|
+
verifies the recorded content root, dtype, and shape before returning.
|
|
1209
|
+
"""
|
|
1210
|
+
_require_jax("restoring one leaf")
|
|
1211
|
+
restore_topology = kwargs.pop("restore_topology", None)
|
|
1212
|
+
axis_arg = kwargs.pop("axis", 0)
|
|
1213
|
+
store, storage_name, metadata, show, _pointer = _resolve_metadata(
|
|
1214
|
+
directory,
|
|
1215
|
+
weftfs_cli=self.weftfs_cli,
|
|
1216
|
+
timeout=self.timeout,
|
|
1217
|
+
service_factory=self._get_service,
|
|
1218
|
+
**kwargs,
|
|
1219
|
+
)
|
|
1220
|
+
_verify_metadata_manifest(metadata, show)
|
|
1221
|
+
stored = metadata.by_path()
|
|
1222
|
+
if path not in stored:
|
|
1223
|
+
raise MissingLeaf(
|
|
1224
|
+
"checkpoint %r has no leaf %r; paths: %r"
|
|
1225
|
+
% (storage_name, path, sorted(stored))
|
|
1226
|
+
)
|
|
1227
|
+
leaf = stored[path]
|
|
1228
|
+
if target is not None:
|
|
1229
|
+
dtype_name, shape, _source = _leaf_dtype_shape(target)
|
|
1230
|
+
if _canonical_dtype(dtype_name) != leaf.dtype or tuple(shape) != leaf.shape:
|
|
1231
|
+
raise MismatchedLeaf(
|
|
1232
|
+
"target leaf %r type/shape disagrees with the stored leaf" % path
|
|
1233
|
+
)
|
|
1234
|
+
shards, axis = (
|
|
1235
|
+
(None, 0)
|
|
1236
|
+
if restore_topology is None
|
|
1237
|
+
else _parse_topology(restore_topology, axis_arg)
|
|
1238
|
+
)
|
|
1239
|
+
buffer = None
|
|
1240
|
+
try:
|
|
1241
|
+
try:
|
|
1242
|
+
buffer = store.restore_tensor(
|
|
1243
|
+
storage_name,
|
|
1244
|
+
leaf.entry,
|
|
1245
|
+
topology=None if shards is None else (shards, axis),
|
|
1246
|
+
expected_size=int(leaf.element_bytes),
|
|
1247
|
+
)
|
|
1248
|
+
except ServiceError as exc:
|
|
1249
|
+
raise self._translate(exc) from exc
|
|
1250
|
+
if tuple(buffer.shape) != leaf.shape:
|
|
1251
|
+
raise ExactnessError(
|
|
1252
|
+
"leaf %r restored shape %r, expected %r"
|
|
1253
|
+
% (path, tuple(buffer.shape), leaf.shape)
|
|
1254
|
+
)
|
|
1255
|
+
actual = content_root(buffer.view)
|
|
1256
|
+
if actual != leaf.content_root:
|
|
1257
|
+
raise ExactnessError(
|
|
1258
|
+
"leaf %r restored content root %s != recorded %s"
|
|
1259
|
+
% (path, actual.hex(), leaf.content_root.hex())
|
|
1260
|
+
)
|
|
1261
|
+
_record_restore_stats(self, 1, buffer.size)
|
|
1262
|
+
if target is None:
|
|
1263
|
+
# The returned numpy array owns its bytes; this is the one
|
|
1264
|
+
# explicit copy on the "no destination given" path.
|
|
1265
|
+
return (
|
|
1266
|
+
_NP.frombuffer(buffer.view, dtype=_numpy_dtype(leaf.dtype))
|
|
1267
|
+
.reshape(leaf.shape)
|
|
1268
|
+
.copy()
|
|
1269
|
+
)
|
|
1270
|
+
return _to_target(
|
|
1271
|
+
buffer, canonical=leaf.dtype, shape=leaf.shape, target=target
|
|
1272
|
+
)
|
|
1273
|
+
finally:
|
|
1274
|
+
if buffer is not None:
|
|
1275
|
+
buffer.close()
|
|
1276
|
+
|
|
1277
|
+
def leaf_paths(self, directory):
|
|
1278
|
+
metadata = metadata_of(Path(directory))
|
|
1279
|
+
return [leaf.path for leaf in metadata.leaves]
|
|
1280
|
+
|
|
1281
|
+
|
|
1282
|
+
if ORBAX_AVAILABLE: # pragma: no branch - registered only with a real Orbax
|
|
1283
|
+
_orbax_args.register_with_handler(WeftOrbaxHandler, for_save=True)(WeftSaveArgs)
|
|
1284
|
+
_orbax_args.register_with_handler(WeftOrbaxHandler, for_restore=True)(
|
|
1285
|
+
WeftRestoreArgs
|
|
1286
|
+
)
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
# ---------------------------------------------------------------------------
|
|
1290
|
+
# Argument / environment helpers
|
|
1291
|
+
|
|
1292
|
+
|
|
1293
|
+
def _extract_args(args, kwargs, expected, operation):
|
|
1294
|
+
value = kwargs.get("args")
|
|
1295
|
+
if value is None:
|
|
1296
|
+
for candidate in args:
|
|
1297
|
+
if isinstance(candidate, expected):
|
|
1298
|
+
value = candidate
|
|
1299
|
+
break
|
|
1300
|
+
if value is None:
|
|
1301
|
+
raise SaveFailed(
|
|
1302
|
+
"%s needs a %s instance (pass args=%s(...))"
|
|
1303
|
+
% (operation, expected.__name__, expected.__name__)
|
|
1304
|
+
)
|
|
1305
|
+
if not isinstance(value, expected):
|
|
1306
|
+
raise SaveFailed(
|
|
1307
|
+
"%s got args of type %s, expected %s"
|
|
1308
|
+
% (operation, type(value).__name__, expected.__name__)
|
|
1309
|
+
)
|
|
1310
|
+
return value
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
def _resolve_save_store_root(store_root):
|
|
1314
|
+
if store_root is None:
|
|
1315
|
+
store_root = os.environ.get("WEFTFS_STORE")
|
|
1316
|
+
if store_root is None:
|
|
1317
|
+
raise StoreUnavailable(
|
|
1318
|
+
"WeftSaveArgs.store_root is required (or set WEFTFS_STORE)"
|
|
1319
|
+
)
|
|
1320
|
+
return Path(os.fspath(store_root))
|
|
1321
|
+
|
|
1322
|
+
|
|
1323
|
+
def _flatten(tree):
|
|
1324
|
+
_require_jax("flattening a PyTree")
|
|
1325
|
+
return _JAX.tree_util.tree_flatten_with_path(tree)[0]
|
|
1326
|
+
|
|
1327
|
+
|
|
1328
|
+
def _treedef(tree):
|
|
1329
|
+
return _JAX.tree_util.tree_structure(tree)
|
|
1330
|
+
|
|
1331
|
+
|
|
1332
|
+
def _entry_names(count):
|
|
1333
|
+
width = max(4, len(str(max(count - 1, 0))))
|
|
1334
|
+
return ["leaf_%0*d" % (width, index) for index in range(count)]
|
|
1335
|
+
|
|
1336
|
+
|
|
1337
|
+
def _json_bytes(value):
|
|
1338
|
+
import json
|
|
1339
|
+
|
|
1340
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
1341
|
+
|
|
1342
|
+
|
|
1343
|
+
def _fsync_directory(path):
|
|
1344
|
+
try:
|
|
1345
|
+
fd = os.open(os.fspath(path), os.O_RDONLY)
|
|
1346
|
+
except OSError:
|
|
1347
|
+
return
|
|
1348
|
+
try:
|
|
1349
|
+
os.fsync(fd)
|
|
1350
|
+
finally:
|
|
1351
|
+
os.close(fd)
|
|
1352
|
+
|
|
1353
|
+
|
|
1354
|
+
def _allocate_storage_name(store, checkpoint_id, overwrite):
|
|
1355
|
+
existing = set(store.list_checkpoints())
|
|
1356
|
+
if checkpoint_id not in existing:
|
|
1357
|
+
return checkpoint_id
|
|
1358
|
+
if not overwrite:
|
|
1359
|
+
raise FileExistsError(
|
|
1360
|
+
"checkpoint %r already exists and overwrite=False" % checkpoint_id
|
|
1361
|
+
)
|
|
1362
|
+
highest = 1
|
|
1363
|
+
for name in existing:
|
|
1364
|
+
generation = _generation(name, checkpoint_id)
|
|
1365
|
+
if generation is not None and generation > highest:
|
|
1366
|
+
highest = generation
|
|
1367
|
+
return "%s.v%d" % (checkpoint_id, highest + 1)
|
|
1368
|
+
|
|
1369
|
+
|
|
1370
|
+
# ---------------------------------------------------------------------------
|
|
1371
|
+
# Manifest / metadata validation
|
|
1372
|
+
|
|
1373
|
+
|
|
1374
|
+
def _verify_manifest(show, leaves, storage_name):
|
|
1375
|
+
manifest = show.get("manifest")
|
|
1376
|
+
if not isinstance(manifest, dict):
|
|
1377
|
+
raise SaveFailed("checkpoint %r has no manifest object" % storage_name)
|
|
1378
|
+
if manifest.get("complete") is not True:
|
|
1379
|
+
raise SaveFailed("checkpoint %r is not complete" % storage_name)
|
|
1380
|
+
rows = manifest.get("tensors")
|
|
1381
|
+
if not isinstance(rows, list) or not rows:
|
|
1382
|
+
raise SaveFailed("checkpoint %r has no tensor list" % storage_name)
|
|
1383
|
+
by_name = {row["name"]: row for row in rows}
|
|
1384
|
+
expected = {leaf["entry"] for leaf in leaves}
|
|
1385
|
+
if set(by_name) != expected:
|
|
1386
|
+
raise SaveFailed(
|
|
1387
|
+
"checkpoint %r stored tensors %r, expected %r"
|
|
1388
|
+
% (storage_name, sorted(by_name), sorted(expected))
|
|
1389
|
+
)
|
|
1390
|
+
for leaf in leaves:
|
|
1391
|
+
row = by_name[leaf["entry"]]
|
|
1392
|
+
if row.get("dtype") != leaf["dtype"]:
|
|
1393
|
+
raise ExactnessError(
|
|
1394
|
+
"leaf %r dtype %r != canonical %r"
|
|
1395
|
+
% (leaf["path"], row.get("dtype"), leaf["dtype"])
|
|
1396
|
+
)
|
|
1397
|
+
if tuple(int(x) for x in row.get("shape", [])) != leaf["shape"]:
|
|
1398
|
+
raise ExactnessError(
|
|
1399
|
+
"leaf %r shape %r != %r"
|
|
1400
|
+
% (leaf["path"], row.get("shape"), leaf["shape"])
|
|
1401
|
+
)
|
|
1402
|
+
if int(row.get("element_bytes", -1)) != leaf["element_bytes"]:
|
|
1403
|
+
raise ExactnessError(
|
|
1404
|
+
"leaf %r element_bytes %r != %r"
|
|
1405
|
+
% (leaf["path"], row.get("element_bytes"), leaf["element_bytes"])
|
|
1406
|
+
)
|
|
1407
|
+
if row.get("content_root") != leaf["content_root"].hex():
|
|
1408
|
+
raise ExactnessError(
|
|
1409
|
+
"leaf %r content root %s != the bytes written (%s)"
|
|
1410
|
+
% (
|
|
1411
|
+
leaf["path"],
|
|
1412
|
+
row.get("content_root"),
|
|
1413
|
+
leaf["content_root"].hex(),
|
|
1414
|
+
)
|
|
1415
|
+
)
|
|
1416
|
+
|
|
1417
|
+
|
|
1418
|
+
def _verify_metadata_manifest(metadata, show):
|
|
1419
|
+
manifest = show.get("manifest")
|
|
1420
|
+
if not isinstance(manifest, dict) or manifest.get("complete") is not True:
|
|
1421
|
+
raise InvalidMetadata(
|
|
1422
|
+
"checkpoint %r manifest is missing or incomplete" % metadata.storage_name
|
|
1423
|
+
)
|
|
1424
|
+
rows = {row["name"]: row for row in manifest.get("tensors", [])}
|
|
1425
|
+
if set(rows) != {leaf.entry for leaf in metadata.leaves}:
|
|
1426
|
+
raise InvalidMetadata(
|
|
1427
|
+
"checkpoint %r manifest tensors %r != metadata entries %r"
|
|
1428
|
+
% (
|
|
1429
|
+
metadata.storage_name,
|
|
1430
|
+
sorted(rows),
|
|
1431
|
+
sorted(leaf.entry for leaf in metadata.leaves),
|
|
1432
|
+
)
|
|
1433
|
+
)
|
|
1434
|
+
for leaf in metadata.leaves:
|
|
1435
|
+
row = rows[leaf.entry]
|
|
1436
|
+
if row.get("dtype") != leaf.dtype:
|
|
1437
|
+
raise ExactnessError(
|
|
1438
|
+
"leaf %r manifest dtype %r != metadata %r"
|
|
1439
|
+
% (leaf.path, row.get("dtype"), leaf.dtype)
|
|
1440
|
+
)
|
|
1441
|
+
if tuple(int(x) for x in row.get("shape", [])) != leaf.shape:
|
|
1442
|
+
raise ExactnessError(
|
|
1443
|
+
"leaf %r manifest shape %r != metadata %r"
|
|
1444
|
+
% (leaf.path, row.get("shape"), leaf.shape)
|
|
1445
|
+
)
|
|
1446
|
+
if int(row.get("element_bytes", -1)) != leaf.element_bytes:
|
|
1447
|
+
raise ExactnessError(
|
|
1448
|
+
"leaf %r manifest element_bytes %r != metadata %r"
|
|
1449
|
+
% (leaf.path, row.get("element_bytes"), leaf.element_bytes)
|
|
1450
|
+
)
|
|
1451
|
+
if row.get("content_root") != leaf.content_root.hex():
|
|
1452
|
+
raise ExactnessError(
|
|
1453
|
+
"leaf %r manifest content root %s != metadata %s"
|
|
1454
|
+
% (leaf.path, row.get("content_root"), leaf.content_root.hex())
|
|
1455
|
+
)
|
|
1456
|
+
if row.get("logical_root") != leaf.logical_root.hex():
|
|
1457
|
+
raise ExactnessError(
|
|
1458
|
+
"leaf %r manifest logical root %s != metadata %s"
|
|
1459
|
+
% (leaf.path, row.get("logical_root"), leaf.logical_root.hex())
|
|
1460
|
+
)
|
|
1461
|
+
|
|
1462
|
+
|
|
1463
|
+
def _check_restored_payloads(parsed, leaves, storage_name):
|
|
1464
|
+
for leaf in leaves:
|
|
1465
|
+
if leaf.entry not in parsed:
|
|
1466
|
+
raise InvalidMetadata(
|
|
1467
|
+
"restored container for %r has no entry %r (has %r)"
|
|
1468
|
+
% (storage_name, leaf.entry, sorted(parsed))
|
|
1469
|
+
)
|
|
1470
|
+
sf_dtype, shape, payload = parsed[leaf.entry]
|
|
1471
|
+
if sf_dtype != _safetensors_dtype(leaf.dtype):
|
|
1472
|
+
raise ExactnessError(
|
|
1473
|
+
"leaf %r restored dtype %s, expected %s"
|
|
1474
|
+
% (leaf.path, sf_dtype, _safetensors_dtype(leaf.dtype))
|
|
1475
|
+
)
|
|
1476
|
+
if tuple(int(x) for x in shape) != leaf.shape:
|
|
1477
|
+
raise ExactnessError(
|
|
1478
|
+
"leaf %r restored shape %r, expected %r"
|
|
1479
|
+
% (leaf.path, tuple(shape), leaf.shape)
|
|
1480
|
+
)
|
|
1481
|
+
actual = content_root(payload)
|
|
1482
|
+
if actual != leaf.content_root:
|
|
1483
|
+
raise ExactnessError(
|
|
1484
|
+
"leaf %r restored content root %s != recorded %s"
|
|
1485
|
+
% (leaf.path, actual.hex(), leaf.content_root.hex())
|
|
1486
|
+
)
|
|
1487
|
+
|
|
1488
|
+
|
|
1489
|
+
def _record_restore_stats(handler, leaves, nbytes):
|
|
1490
|
+
with handler._lock:
|
|
1491
|
+
handler._stats["restores"] += 1
|
|
1492
|
+
handler._stats["leaves_restored"] += leaves
|
|
1493
|
+
handler._stats["restored_bytes"] += nbytes
|
|
1494
|
+
|
|
1495
|
+
|
|
1496
|
+
# ---------------------------------------------------------------------------
|
|
1497
|
+
# Locator / metadata resolution
|
|
1498
|
+
|
|
1499
|
+
|
|
1500
|
+
def read_pointer(directory):
|
|
1501
|
+
path = Path(directory) / ORBAX_POINTER_NAME
|
|
1502
|
+
try:
|
|
1503
|
+
raw = path.read_bytes()
|
|
1504
|
+
except FileNotFoundError as exc:
|
|
1505
|
+
raise MetadataMissing(
|
|
1506
|
+
"directory %s has no %s locator" % (directory, ORBAX_POINTER_NAME)
|
|
1507
|
+
) from exc
|
|
1508
|
+
try:
|
|
1509
|
+
value = wcb.decode(raw)
|
|
1510
|
+
except Exception as exc:
|
|
1511
|
+
raise InvalidMetadata(
|
|
1512
|
+
"orbax locator at %s is not canonical WCB: %s" % (path, exc)
|
|
1513
|
+
) from exc
|
|
1514
|
+
return pointer_from_value(value)
|
|
1515
|
+
|
|
1516
|
+
|
|
1517
|
+
def _resolve_metadata(
|
|
1518
|
+
directory,
|
|
1519
|
+
*,
|
|
1520
|
+
store_root=None,
|
|
1521
|
+
checkpoint_id=None,
|
|
1522
|
+
storage_name=None,
|
|
1523
|
+
weftfs_cli=None,
|
|
1524
|
+
timeout=DEFAULT_TIMEOUT_SECONDS,
|
|
1525
|
+
service=None,
|
|
1526
|
+
service_factory=None,
|
|
1527
|
+
):
|
|
1528
|
+
directory = Path(os.fspath(directory))
|
|
1529
|
+
pointer = None
|
|
1530
|
+
pointer_path = directory / ORBAX_POINTER_NAME
|
|
1531
|
+
if pointer_path.is_file():
|
|
1532
|
+
pointer = read_pointer(directory)
|
|
1533
|
+
if store_root is None:
|
|
1534
|
+
if pointer is None:
|
|
1535
|
+
raise StoreUnavailable(
|
|
1536
|
+
"no %s in %s and no store_root given"
|
|
1537
|
+
% (ORBAX_POINTER_NAME, directory)
|
|
1538
|
+
)
|
|
1539
|
+
store_root = pointer["store_root"]
|
|
1540
|
+
store_root = Path(os.fspath(store_root))
|
|
1541
|
+
if pointer is not None:
|
|
1542
|
+
if os.path.abspath(os.fspath(store_root)) != os.path.abspath(
|
|
1543
|
+
pointer["store_root"]
|
|
1544
|
+
):
|
|
1545
|
+
raise InvalidMetadata(
|
|
1546
|
+
"explicit store_root %s != locator store_root %s"
|
|
1547
|
+
% (store_root, pointer["store_root"])
|
|
1548
|
+
)
|
|
1549
|
+
if checkpoint_id is not None and checkpoint_id != pointer["checkpoint_id"]:
|
|
1550
|
+
raise InvalidMetadata(
|
|
1551
|
+
"explicit checkpoint_id %r != locator checkpoint_id %r"
|
|
1552
|
+
% (checkpoint_id, pointer["checkpoint_id"])
|
|
1553
|
+
)
|
|
1554
|
+
if storage_name is not None and storage_name != pointer["storage_name"]:
|
|
1555
|
+
raise InvalidMetadata(
|
|
1556
|
+
"explicit storage_name %r != locator storage_name %r"
|
|
1557
|
+
% (storage_name, pointer["storage_name"])
|
|
1558
|
+
)
|
|
1559
|
+
checkpoint_id = pointer["checkpoint_id"]
|
|
1560
|
+
storage_name = pointer["storage_name"]
|
|
1561
|
+
if service is None:
|
|
1562
|
+
if service_factory is not None:
|
|
1563
|
+
service = service_factory(store_root)
|
|
1564
|
+
else:
|
|
1565
|
+
service = CheckpointService(store_root, cli=weftfs_cli, timeout=timeout)
|
|
1566
|
+
store = service
|
|
1567
|
+
# Refresh before reading so a commit made by another process (the CLI oracle
|
|
1568
|
+
# in the adversarial tests, for example) is visible on this long-lived
|
|
1569
|
+
# service. The refresh reopens the read handle; it is not a write.
|
|
1570
|
+
store.refresh()
|
|
1571
|
+
if storage_name is None:
|
|
1572
|
+
if checkpoint_id is None:
|
|
1573
|
+
raise InvalidMetadata(
|
|
1574
|
+
"restore needs checkpoint_id or storage_name when the directory "
|
|
1575
|
+
"has no locator"
|
|
1576
|
+
)
|
|
1577
|
+
storage_name = _latest_storage_name(store, checkpoint_id)
|
|
1578
|
+
elif checkpoint_id is not None:
|
|
1579
|
+
if _generation(storage_name, checkpoint_id) is None:
|
|
1580
|
+
raise InvalidMetadata(
|
|
1581
|
+
"storage_name %r is not a generation of checkpoint_id %r"
|
|
1582
|
+
% (storage_name, checkpoint_id)
|
|
1583
|
+
)
|
|
1584
|
+
metadata_path = "/checkpoints/%s/%s" % (storage_name, ORBAX_METADATA_NAME)
|
|
1585
|
+
exists, _size = store.stat_file(metadata_path)
|
|
1586
|
+
if not exists:
|
|
1587
|
+
raise MetadataMissing(
|
|
1588
|
+
"checkpoint %r has no %s; was it written by the Orbax adapter?"
|
|
1589
|
+
% (metadata_path, ORBAX_METADATA_NAME)
|
|
1590
|
+
)
|
|
1591
|
+
with store.read_file(metadata_path) as view:
|
|
1592
|
+
blob = view.bytes
|
|
1593
|
+
if pointer is not None and content_root(blob) != pointer["metadata_root"]:
|
|
1594
|
+
raise InvalidMetadata(
|
|
1595
|
+
"store metadata root %s != locator metadata_root %s"
|
|
1596
|
+
% (content_root(blob).hex(), pointer["metadata_root"].hex())
|
|
1597
|
+
)
|
|
1598
|
+
try:
|
|
1599
|
+
value = wcb.decode(blob)
|
|
1600
|
+
except Exception as exc:
|
|
1601
|
+
raise InvalidMetadata(
|
|
1602
|
+
"store metadata at %s is not canonical WCB: %s" % (metadata_path, exc)
|
|
1603
|
+
) from exc
|
|
1604
|
+
metadata = metadata_from_value(value)
|
|
1605
|
+
if metadata.storage_name != storage_name:
|
|
1606
|
+
raise InvalidMetadata(
|
|
1607
|
+
"store metadata names storage %r but resolved %r"
|
|
1608
|
+
% (metadata.storage_name, storage_name)
|
|
1609
|
+
)
|
|
1610
|
+
if checkpoint_id is not None and metadata.checkpoint_id != checkpoint_id:
|
|
1611
|
+
raise InvalidMetadata(
|
|
1612
|
+
"store metadata names checkpoint %r but resolved %r"
|
|
1613
|
+
% (metadata.checkpoint_id, checkpoint_id)
|
|
1614
|
+
)
|
|
1615
|
+
show = _show_from_manifest(store.manifest(storage_name))
|
|
1616
|
+
return store, storage_name, metadata, show, pointer
|
|
1617
|
+
|
|
1618
|
+
|
|
1619
|
+
def _show_from_manifest(descriptor):
|
|
1620
|
+
return {
|
|
1621
|
+
"manifest_root": descriptor.manifest_root,
|
|
1622
|
+
"manifest": descriptor.to_value(),
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
|
|
1626
|
+
def _translate_service_error(exc):
|
|
1627
|
+
"""Map a typed service error onto this adapter's error hierarchy."""
|
|
1628
|
+
message = str(exc)
|
|
1629
|
+
class_name = getattr(exc, "class_name", "")
|
|
1630
|
+
if class_name == "NotFound":
|
|
1631
|
+
return MetadataMissing(message)
|
|
1632
|
+
if class_name in ("Corrupt", "Malformed", "NonCanonical", "DependencyMismatch"):
|
|
1633
|
+
return ExactnessError(message)
|
|
1634
|
+
if class_name in ("LimitExceeded", "BudgetExceeded"):
|
|
1635
|
+
return StagingLimitExceeded(message)
|
|
1636
|
+
return SaveFailed(message)
|
|
1637
|
+
|
|
1638
|
+
|
|
1639
|
+
def _latest_storage_name(store, checkpoint_id):
|
|
1640
|
+
names = set(store.list_checkpoints())
|
|
1641
|
+
highest = 1
|
|
1642
|
+
chosen = None
|
|
1643
|
+
for name in names:
|
|
1644
|
+
generation = _generation(name, checkpoint_id)
|
|
1645
|
+
if generation is not None and generation >= highest:
|
|
1646
|
+
highest = generation
|
|
1647
|
+
chosen = name
|
|
1648
|
+
if chosen is None:
|
|
1649
|
+
raise MetadataMissing("checkpoint %r does not exist in the store" % checkpoint_id)
|
|
1650
|
+
return chosen
|
|
1651
|
+
|
|
1652
|
+
|
|
1653
|
+
def read_metadata(directory, **kwargs):
|
|
1654
|
+
"""Read and validate the canonical metadata for an Orbax directory."""
|
|
1655
|
+
return _resolve_metadata(Path(directory), **kwargs)
|
|
1656
|
+
|
|
1657
|
+
|
|
1658
|
+
# ---------------------------------------------------------------------------
|
|
1659
|
+
# Convenience wrappers
|
|
1660
|
+
|
|
1661
|
+
|
|
1662
|
+
def save_pytree(item, *, directory, store_root, checkpoint_id, **kwargs):
|
|
1663
|
+
"""Synchronous save without constructing a handler by hand."""
|
|
1664
|
+
handler = WeftOrbaxHandler(
|
|
1665
|
+
staging_bytes=kwargs.pop("handler_staging_bytes", DEFAULT_STAGING_BYTES),
|
|
1666
|
+
max_workers=kwargs.pop("max_workers", 1),
|
|
1667
|
+
weftfs_cli=kwargs.pop("weftfs_cli", None),
|
|
1668
|
+
timeout=kwargs.pop("timeout", DEFAULT_TIMEOUT_SECONDS),
|
|
1669
|
+
)
|
|
1670
|
+
try:
|
|
1671
|
+
args = WeftSaveArgs(
|
|
1672
|
+
item=item,
|
|
1673
|
+
store_root=store_root,
|
|
1674
|
+
checkpoint_id=checkpoint_id,
|
|
1675
|
+
**kwargs,
|
|
1676
|
+
)
|
|
1677
|
+
return handler.save(directory, args)
|
|
1678
|
+
finally:
|
|
1679
|
+
handler.close()
|
|
1680
|
+
|
|
1681
|
+
|
|
1682
|
+
def save_async_pytree(item, *, directory, store_root, checkpoint_id, **kwargs):
|
|
1683
|
+
"""Run a save on one bounded worker; returns a DCP-style async handle."""
|
|
1684
|
+
handler = WeftOrbaxHandler(
|
|
1685
|
+
staging_bytes=kwargs.pop("handler_staging_bytes", DEFAULT_STAGING_BYTES),
|
|
1686
|
+
max_workers=kwargs.pop("max_workers", 1),
|
|
1687
|
+
weftfs_cli=kwargs.pop("weftfs_cli", None),
|
|
1688
|
+
timeout=kwargs.pop("timeout", DEFAULT_TIMEOUT_SECONDS),
|
|
1689
|
+
)
|
|
1690
|
+
executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="weft-orbax-async")
|
|
1691
|
+
args = WeftSaveArgs(
|
|
1692
|
+
item=item,
|
|
1693
|
+
store_root=store_root,
|
|
1694
|
+
checkpoint_id=checkpoint_id,
|
|
1695
|
+
**kwargs,
|
|
1696
|
+
)
|
|
1697
|
+
future = executor.submit(handler.save, Path(os.fspath(directory)), args)
|
|
1698
|
+
future.add_done_callback(lambda _future: handler.close())
|
|
1699
|
+
return AsyncSaveHandle(future, handler, executor)
|
|
1700
|
+
|
|
1701
|
+
|
|
1702
|
+
def restore_pytree(directory, item, **kwargs):
|
|
1703
|
+
"""Synchronous restore without constructing a handler by hand."""
|
|
1704
|
+
handler = WeftOrbaxHandler(
|
|
1705
|
+
staging_bytes=kwargs.pop("handler_staging_bytes", DEFAULT_STAGING_BYTES),
|
|
1706
|
+
weftfs_cli=kwargs.pop("weftfs_cli", None),
|
|
1707
|
+
timeout=kwargs.pop("timeout", DEFAULT_TIMEOUT_SECONDS),
|
|
1708
|
+
)
|
|
1709
|
+
try:
|
|
1710
|
+
return handler.restore(directory, WeftRestoreArgs(item=item, **kwargs))
|
|
1711
|
+
finally:
|
|
1712
|
+
handler.close()
|
|
1713
|
+
|
|
1714
|
+
|
|
1715
|
+
def leaf_paths(item):
|
|
1716
|
+
"""Canonical paths of each leaf of a PyTree, in flatten order."""
|
|
1717
|
+
return [_canonical_path(path_tuple) for path_tuple, _ in _flatten(item)]
|
|
1718
|
+
|
|
1719
|
+
|
|
1720
|
+
def flatten_pytree(item):
|
|
1721
|
+
"""``[(canonical_path, leaf)]`` in JAX flatten order."""
|
|
1722
|
+
return [(_canonical_path(path_tuple), leaf) for path_tuple, leaf in _flatten(item)]
|
|
1723
|
+
|
|
1724
|
+
|
|
1725
|
+
def metadata_of(directory, **kwargs):
|
|
1726
|
+
"""Return the decoded :class:`OrbaxMetadata` for a directory."""
|
|
1727
|
+
resolved = read_metadata(Path(directory), **kwargs)
|
|
1728
|
+
service = resolved[0]
|
|
1729
|
+
try:
|
|
1730
|
+
return resolved[2]
|
|
1731
|
+
finally:
|
|
1732
|
+
close = getattr(service, "close", None)
|
|
1733
|
+
if callable(close):
|
|
1734
|
+
close()
|