stringjax-tools 0.1.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.
- stringjax_tools/__init__.py +68 -0
- stringjax_tools/__main__.py +25 -0
- stringjax_tools/_utils.py +201 -0
- stringjax_tools/auto_vectorise.py +581 -0
- stringjax_tools/cache.py +86 -0
- stringjax_tools/jit.py +82 -0
- stringjax_tools/py.typed +1 -0
- stringjax_tools/pytrees.py +331 -0
- stringjax_tools/vmap.py +114 -0
- stringjax_tools-0.1.0.dist-info/METADATA +215 -0
- stringjax_tools-0.1.0.dist-info/RECORD +14 -0
- stringjax_tools-0.1.0.dist-info/WHEEL +5 -0
- stringjax_tools-0.1.0.dist-info/licenses/LICENSE +674 -0
- stringjax_tools-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
# Copyright 2022-2026 Andreas Schachner
|
|
2
|
+
#
|
|
3
|
+
# This file is part of StringJAX Tools.
|
|
4
|
+
#
|
|
5
|
+
# StringJAX Tools is free software: you can redistribute it and/or modify
|
|
6
|
+
# it under the terms of the GNU General Public License as published by
|
|
7
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
8
|
+
# (at your option) any later version.
|
|
9
|
+
#
|
|
10
|
+
# StringJAX Tools is distributed in the hope that it will be useful,
|
|
11
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
# GNU General Public License for more details.
|
|
14
|
+
#
|
|
15
|
+
# You should have received a copy of the GNU General Public License
|
|
16
|
+
# along with StringJAX Tools. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
|
|
18
|
+
"""Rank-checked automatic vectorisation helpers.
|
|
19
|
+
|
|
20
|
+
The central helper is :func:`auto_vmap`, a conservative decorator for functions
|
|
21
|
+
whose single-sample input ranks are known. At call time, selected arguments are
|
|
22
|
+
checked before JAX tracing. Rank ``r`` is treated as one sample, rank ``r + 1``
|
|
23
|
+
as a paired leading-axis batch, and checked batched arguments must share the
|
|
24
|
+
same leading size.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import inspect
|
|
30
|
+
from collections import OrderedDict
|
|
31
|
+
from collections.abc import Iterator
|
|
32
|
+
from contextlib import contextmanager
|
|
33
|
+
from dataclasses import dataclass
|
|
34
|
+
from functools import wraps
|
|
35
|
+
from numbers import Integral
|
|
36
|
+
from typing import Any, Callable, Mapping, Sequence, TypeAlias
|
|
37
|
+
|
|
38
|
+
import jax
|
|
39
|
+
import jax.numpy as jnp
|
|
40
|
+
|
|
41
|
+
from ._utils import cache_key_value, lru_cache_get, lru_cache_set, snapshot_static_value
|
|
42
|
+
|
|
43
|
+
Shape = tuple[int, ...]
|
|
44
|
+
ShapeLike = int | Sequence[int] | None
|
|
45
|
+
ShapeSpec = Any
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"ArgSpec",
|
|
49
|
+
"auto_vmap",
|
|
50
|
+
"auto_vmap_default_shapes",
|
|
51
|
+
"auto_vmap_defaults",
|
|
52
|
+
"clear_auto_vmap_caches",
|
|
53
|
+
"get_auto_vmap_default_shapes",
|
|
54
|
+
"get_auto_vmap_defaults",
|
|
55
|
+
"reset_auto_vmap_default_shapes",
|
|
56
|
+
"reset_auto_vmap_defaults",
|
|
57
|
+
"set_auto_vmap_default_shapes",
|
|
58
|
+
"set_auto_vmap_defaults",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
_AUTO_VMAP_CACHE_MAXSIZE = 256
|
|
62
|
+
_AUTO_VMAP_CACHE: OrderedDict[tuple[Any, ...], Callable[..., Any]] = OrderedDict()
|
|
63
|
+
_AUTO_VMAP_DEFAULT_RANKS: dict[str, int] = {}
|
|
64
|
+
_AUTO_VMAP_DEFAULT_SHAPES: dict[str, ShapeSpec] = {}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _is_nonnegative_int(value: Any) -> bool:
|
|
68
|
+
r"""Return whether ``value`` is a valid non-negative integer dimension."""
|
|
69
|
+
return isinstance(value, Integral) and not isinstance(value, bool) and int(value) >= 0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _normalise_rank(name: str, rank: Any) -> int:
|
|
73
|
+
r"""Normalise and validate a sample-rank declaration."""
|
|
74
|
+
if not _is_nonnegative_int(rank):
|
|
75
|
+
raise ValueError(
|
|
76
|
+
f"Sample rank for argument {name!r} must be a non-negative integer, "
|
|
77
|
+
f"got {rank!r}."
|
|
78
|
+
)
|
|
79
|
+
return int(rank)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _normalise_rank_mapping(ranks: Mapping[str, Any] | None) -> dict[str, int]:
|
|
83
|
+
r"""Normalise a mapping of argument names to sample ranks."""
|
|
84
|
+
if ranks is None:
|
|
85
|
+
return {}
|
|
86
|
+
return {name: _normalise_rank(name, rank) for name, rank in dict(ranks).items()}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _normalise_concrete_shape(shape: ShapeLike) -> Shape:
|
|
90
|
+
r"""Return a concrete integer shape as a tuple."""
|
|
91
|
+
if shape is None:
|
|
92
|
+
return ()
|
|
93
|
+
if _is_nonnegative_int(shape):
|
|
94
|
+
return (int(shape),)
|
|
95
|
+
if isinstance(shape, str):
|
|
96
|
+
raise ValueError(f"Concrete shapes cannot contain unresolved string {shape!r}.")
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
dims = tuple(shape)
|
|
100
|
+
except TypeError as exc:
|
|
101
|
+
raise ValueError(f"Could not interpret shape {shape!r}.") from exc
|
|
102
|
+
|
|
103
|
+
if not all(_is_nonnegative_int(dim) for dim in dims):
|
|
104
|
+
raise ValueError(f"Shape entries must be non-negative integers, got {dims}.")
|
|
105
|
+
return tuple(int(dim) for dim in dims)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class ArgSpec:
|
|
110
|
+
r"""
|
|
111
|
+
Optional exact trailing sample-shape specification.
|
|
112
|
+
|
|
113
|
+
``ArgSpec`` is not needed for the common rank-only API. It is retained as
|
|
114
|
+
a readable wrapper for entries in ``sample_shapes`` or global shape
|
|
115
|
+
defaults.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
shape: Expected trailing sample shape. Use ``None`` or ``()`` for a
|
|
119
|
+
scalar, an ``int`` for a fixed one-dimensional sample, a tuple/list
|
|
120
|
+
for higher-rank samples, a string for attribute lookup on the first
|
|
121
|
+
bound object exposing that attribute, or a callable receiving the
|
|
122
|
+
bound argument mapping and returning a concrete shape.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
shape: ShapeSpec = ()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
ShapeSpecLike: TypeAlias = ArgSpec | ShapeSpec
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _argument_shape(value: Any) -> Shape:
|
|
132
|
+
r"""Return the runtime shape of an argument without entering a JAX trace."""
|
|
133
|
+
try:
|
|
134
|
+
return tuple(jnp.shape(value))
|
|
135
|
+
except Exception as exc:
|
|
136
|
+
raise ValueError(f"Could not determine shape of argument {value!r}.") from exc
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _find_attribute_owner(
|
|
140
|
+
attr_name: str,
|
|
141
|
+
bound_arguments: Mapping[str, Any],
|
|
142
|
+
) -> Any:
|
|
143
|
+
r"""Find the first bound object exposing ``attr_name``."""
|
|
144
|
+
for preferred in ("self", "cls"):
|
|
145
|
+
owner = bound_arguments.get(preferred)
|
|
146
|
+
if owner is not None and hasattr(owner, attr_name):
|
|
147
|
+
return owner
|
|
148
|
+
|
|
149
|
+
for owner in bound_arguments.values():
|
|
150
|
+
if hasattr(owner, attr_name):
|
|
151
|
+
return owner
|
|
152
|
+
|
|
153
|
+
raise ValueError(
|
|
154
|
+
f"Could not resolve shape attribute {attr_name!r}; no bound argument "
|
|
155
|
+
"exposes this attribute."
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _resolve_shape_dim(dim: Any, bound_arguments: Mapping[str, Any]) -> int:
|
|
160
|
+
r"""Resolve one dimension in a shape specification."""
|
|
161
|
+
if _is_nonnegative_int(dim):
|
|
162
|
+
return int(dim)
|
|
163
|
+
if isinstance(dim, str):
|
|
164
|
+
value = getattr(_find_attribute_owner(dim, bound_arguments), dim)
|
|
165
|
+
if not _is_nonnegative_int(value):
|
|
166
|
+
raise ValueError(
|
|
167
|
+
f"Shape attribute {dim!r} resolved to {value!r}, but dimensions "
|
|
168
|
+
"must be non-negative integers."
|
|
169
|
+
)
|
|
170
|
+
return int(value)
|
|
171
|
+
raise ValueError(f"Unsupported shape dimension specification {dim!r}.")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _resolve_shape_spec(
|
|
175
|
+
spec: ShapeSpecLike,
|
|
176
|
+
bound_arguments: Mapping[str, Any],
|
|
177
|
+
) -> Shape:
|
|
178
|
+
r"""Resolve an optional exact sample-shape specification."""
|
|
179
|
+
if isinstance(spec, ArgSpec):
|
|
180
|
+
spec = spec.shape
|
|
181
|
+
|
|
182
|
+
if callable(spec):
|
|
183
|
+
return _normalise_concrete_shape(spec(bound_arguments))
|
|
184
|
+
if spec is None:
|
|
185
|
+
return ()
|
|
186
|
+
if _is_nonnegative_int(spec):
|
|
187
|
+
return (int(spec),)
|
|
188
|
+
if isinstance(spec, str):
|
|
189
|
+
return (_resolve_shape_dim(spec, bound_arguments),)
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
dims = tuple(spec)
|
|
193
|
+
except TypeError as exc:
|
|
194
|
+
raise ValueError(f"Could not interpret shape specification {spec!r}.") from exc
|
|
195
|
+
|
|
196
|
+
return tuple(_resolve_shape_dim(dim, bound_arguments) for dim in dims)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _infer_vmap_axis(
|
|
200
|
+
name: str,
|
|
201
|
+
value: Any,
|
|
202
|
+
sample_rank: int,
|
|
203
|
+
) -> tuple[int | None, int | None, Shape]:
|
|
204
|
+
r"""
|
|
205
|
+
Infer whether ``value`` is unbatched or carries one leading batch axis.
|
|
206
|
+
|
|
207
|
+
Returns ``(axis, batch_size, sample_shape)``.
|
|
208
|
+
"""
|
|
209
|
+
shape = _argument_shape(value)
|
|
210
|
+
ndim = len(shape)
|
|
211
|
+
|
|
212
|
+
if ndim == sample_rank:
|
|
213
|
+
return None, None, shape
|
|
214
|
+
if ndim == sample_rank + 1:
|
|
215
|
+
return 0, shape[0], shape[1:]
|
|
216
|
+
|
|
217
|
+
raise ValueError(
|
|
218
|
+
f"Argument {name!r} has shape {shape}; expected sample rank "
|
|
219
|
+
f"{sample_rank} or a leading-axis batch with rank {sample_rank + 1}."
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _call_with_bound_arguments(
|
|
224
|
+
func: Callable[..., Any],
|
|
225
|
+
parameters: Mapping[str, inspect.Parameter],
|
|
226
|
+
arguments: Mapping[str, Any],
|
|
227
|
+
) -> Any:
|
|
228
|
+
r"""Call ``func`` from an ordered mapping of bound argument values."""
|
|
229
|
+
positional: list[Any] = []
|
|
230
|
+
keywords: dict[str, Any] = {}
|
|
231
|
+
|
|
232
|
+
for name, parameter in parameters.items():
|
|
233
|
+
if name not in arguments:
|
|
234
|
+
continue
|
|
235
|
+
|
|
236
|
+
value = arguments[name]
|
|
237
|
+
if parameter.kind is inspect.Parameter.POSITIONAL_ONLY:
|
|
238
|
+
positional.append(value)
|
|
239
|
+
elif parameter.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD:
|
|
240
|
+
positional.append(value)
|
|
241
|
+
elif parameter.kind is inspect.Parameter.VAR_POSITIONAL:
|
|
242
|
+
positional.extend(value)
|
|
243
|
+
elif parameter.kind is inspect.Parameter.KEYWORD_ONLY:
|
|
244
|
+
keywords[name] = value
|
|
245
|
+
elif parameter.kind is inspect.Parameter.VAR_KEYWORD:
|
|
246
|
+
keywords.update(value)
|
|
247
|
+
|
|
248
|
+
return func(*positional, **keywords)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def set_auto_vmap_defaults(**sample_ranks: Any) -> dict[str, int]:
|
|
252
|
+
r"""Update global default sample ranks used by :func:`auto_vmap`."""
|
|
253
|
+
_AUTO_VMAP_DEFAULT_RANKS.update(_normalise_rank_mapping(sample_ranks))
|
|
254
|
+
return get_auto_vmap_defaults()
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def get_auto_vmap_defaults() -> dict[str, int]:
|
|
258
|
+
r"""Return a copy of the active global sample-rank defaults."""
|
|
259
|
+
return dict(_AUTO_VMAP_DEFAULT_RANKS)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def reset_auto_vmap_defaults() -> None:
|
|
263
|
+
r"""Clear all global sample-rank defaults."""
|
|
264
|
+
_AUTO_VMAP_DEFAULT_RANKS.clear()
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@contextmanager
|
|
268
|
+
def auto_vmap_defaults(**sample_ranks: Any) -> Iterator[dict[str, int]]:
|
|
269
|
+
r"""Temporarily update global sample-rank defaults inside a ``with`` block."""
|
|
270
|
+
previous = get_auto_vmap_defaults()
|
|
271
|
+
set_auto_vmap_defaults(**sample_ranks)
|
|
272
|
+
try:
|
|
273
|
+
yield get_auto_vmap_defaults()
|
|
274
|
+
finally:
|
|
275
|
+
_AUTO_VMAP_DEFAULT_RANKS.clear()
|
|
276
|
+
_AUTO_VMAP_DEFAULT_RANKS.update(previous)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def set_auto_vmap_default_shapes(**sample_shapes: ShapeSpecLike) -> dict[str, ShapeSpec]:
|
|
280
|
+
r"""
|
|
281
|
+
Update global default exact sample-shape specifications.
|
|
282
|
+
|
|
283
|
+
These defaults are used only when :func:`auto_vmap` is called with
|
|
284
|
+
``validate_shapes=True``.
|
|
285
|
+
"""
|
|
286
|
+
_AUTO_VMAP_DEFAULT_SHAPES.update(sample_shapes)
|
|
287
|
+
return get_auto_vmap_default_shapes()
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def get_auto_vmap_default_shapes() -> dict[str, ShapeSpec]:
|
|
291
|
+
r"""Return a copy of the active global sample-shape defaults."""
|
|
292
|
+
return dict(_AUTO_VMAP_DEFAULT_SHAPES)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def reset_auto_vmap_default_shapes() -> None:
|
|
296
|
+
r"""Clear all global sample-shape defaults."""
|
|
297
|
+
_AUTO_VMAP_DEFAULT_SHAPES.clear()
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@contextmanager
|
|
301
|
+
def auto_vmap_default_shapes(**sample_shapes: ShapeSpecLike) -> Iterator[dict[str, ShapeSpec]]:
|
|
302
|
+
r"""Temporarily update global sample-shape defaults inside a ``with`` block."""
|
|
303
|
+
previous = get_auto_vmap_default_shapes()
|
|
304
|
+
set_auto_vmap_default_shapes(**sample_shapes)
|
|
305
|
+
try:
|
|
306
|
+
yield get_auto_vmap_default_shapes()
|
|
307
|
+
finally:
|
|
308
|
+
_AUTO_VMAP_DEFAULT_SHAPES.clear()
|
|
309
|
+
_AUTO_VMAP_DEFAULT_SHAPES.update(previous)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _validate_local_names(
|
|
313
|
+
kind: str,
|
|
314
|
+
names: Sequence[str],
|
|
315
|
+
parameters: Mapping[str, inspect.Parameter],
|
|
316
|
+
func_name: str,
|
|
317
|
+
) -> None:
|
|
318
|
+
r"""Validate local decorator declarations against a function signature."""
|
|
319
|
+
missing = sorted(name for name in names if name not in parameters)
|
|
320
|
+
if missing:
|
|
321
|
+
raise ValueError(
|
|
322
|
+
f"auto_vmap {kind} refer to unknown argument(s) {missing} "
|
|
323
|
+
f"for function {func_name!r}."
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
unsupported = sorted(
|
|
327
|
+
name
|
|
328
|
+
for name in names
|
|
329
|
+
if parameters[name].kind
|
|
330
|
+
in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD)
|
|
331
|
+
)
|
|
332
|
+
if unsupported:
|
|
333
|
+
raise ValueError(
|
|
334
|
+
"auto_vmap cannot infer ranks or shapes for variadic argument(s) "
|
|
335
|
+
f"{unsupported} in function {func_name!r}."
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _active_ranks(
|
|
340
|
+
parameters: Mapping[str, inspect.Parameter],
|
|
341
|
+
local_ranks: Mapping[str, int],
|
|
342
|
+
) -> dict[str, int]:
|
|
343
|
+
r"""Merge applicable global ranks with local rank overrides."""
|
|
344
|
+
ranks = {
|
|
345
|
+
name: rank
|
|
346
|
+
for name, rank in _AUTO_VMAP_DEFAULT_RANKS.items()
|
|
347
|
+
if name in parameters
|
|
348
|
+
}
|
|
349
|
+
ranks.update(local_ranks)
|
|
350
|
+
return ranks
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _active_shapes(
|
|
354
|
+
parameters: Mapping[str, inspect.Parameter],
|
|
355
|
+
local_shapes: Mapping[str, ShapeSpecLike],
|
|
356
|
+
) -> dict[str, ShapeSpecLike]:
|
|
357
|
+
r"""Merge applicable global shapes with local shape overrides."""
|
|
358
|
+
shapes = {
|
|
359
|
+
name: spec
|
|
360
|
+
for name, spec in _AUTO_VMAP_DEFAULT_SHAPES.items()
|
|
361
|
+
if name in parameters
|
|
362
|
+
}
|
|
363
|
+
shapes.update(local_shapes)
|
|
364
|
+
return shapes
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def auto_vmap(
|
|
368
|
+
sample_ranks: Mapping[str, int] | None = None,
|
|
369
|
+
*,
|
|
370
|
+
sample_shapes: Mapping[str, ShapeSpecLike] | None = None,
|
|
371
|
+
validate_shapes: bool = False,
|
|
372
|
+
jit: bool = True,
|
|
373
|
+
**named_sample_ranks: int,
|
|
374
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
375
|
+
r"""
|
|
376
|
+
Decorate a single-sample function with conservative automatic batching.
|
|
377
|
+
|
|
378
|
+
Sample ranks decide batching. For an argument declared with sample rank
|
|
379
|
+
``r``, rank ``r`` is treated as one unbatched sample and rank ``r + 1`` is
|
|
380
|
+
treated as a leading-axis batch. If several selected arguments are
|
|
381
|
+
batched, their leading batch sizes must agree. Exact trailing-shape
|
|
382
|
+
validation is optional and separate from batching detection.
|
|
383
|
+
"""
|
|
384
|
+
local_ranks = _normalise_rank_mapping(sample_ranks)
|
|
385
|
+
local_ranks.update(_normalise_rank_mapping(named_sample_ranks))
|
|
386
|
+
local_shapes = dict(sample_shapes or {})
|
|
387
|
+
|
|
388
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
389
|
+
signature = inspect.signature(func)
|
|
390
|
+
parameters = signature.parameters
|
|
391
|
+
|
|
392
|
+
_validate_local_names("sample ranks", tuple(local_ranks), parameters, func.__name__)
|
|
393
|
+
_validate_local_names("sample shapes", tuple(local_shapes), parameters, func.__name__)
|
|
394
|
+
|
|
395
|
+
@wraps(func)
|
|
396
|
+
def wrapped(*args: Any, **kwargs: Any) -> Any:
|
|
397
|
+
bound = signature.bind(*args, **kwargs)
|
|
398
|
+
bound.apply_defaults()
|
|
399
|
+
|
|
400
|
+
ranks = _active_ranks(parameters, local_ranks)
|
|
401
|
+
shape_specs = _active_shapes(parameters, local_shapes)
|
|
402
|
+
if not ranks:
|
|
403
|
+
if validate_shapes and shape_specs:
|
|
404
|
+
raise ValueError(
|
|
405
|
+
"auto_vmap shape specifications require matching "
|
|
406
|
+
f"sample-rank declarations; no ranks are active for "
|
|
407
|
+
f"{sorted(shape_specs)} in function {func.__name__!r}."
|
|
408
|
+
)
|
|
409
|
+
raise ValueError(
|
|
410
|
+
f"auto_vmap has no sample-rank declarations for function "
|
|
411
|
+
f"{func.__name__!r}. Pass ranks to auto_vmap or configure "
|
|
412
|
+
"global defaults with set_auto_vmap_defaults(...)."
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
_validate_local_names("active sample ranks", tuple(ranks), parameters, func.__name__)
|
|
416
|
+
if validate_shapes:
|
|
417
|
+
unchecked_shape_specs = sorted(set(shape_specs) - set(ranks))
|
|
418
|
+
if unchecked_shape_specs:
|
|
419
|
+
raise ValueError(
|
|
420
|
+
"auto_vmap shape specifications require matching "
|
|
421
|
+
f"sample-rank declarations; no ranks are active for "
|
|
422
|
+
f"{unchecked_shape_specs} in function {func.__name__!r}."
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
batch_size: int | None = None
|
|
426
|
+
dynamic_values: list[Any] = []
|
|
427
|
+
dynamic_names: list[str] = []
|
|
428
|
+
in_axes: list[int | None] = []
|
|
429
|
+
resolved_shapes: dict[str, Shape] = {}
|
|
430
|
+
resolved_rank_items: list[tuple[str, int]] = []
|
|
431
|
+
|
|
432
|
+
for name, sample_rank in ranks.items():
|
|
433
|
+
axis, size, sample_shape = _infer_vmap_axis(
|
|
434
|
+
name,
|
|
435
|
+
bound.arguments[name],
|
|
436
|
+
sample_rank,
|
|
437
|
+
)
|
|
438
|
+
dynamic_names.append(name)
|
|
439
|
+
dynamic_values.append(bound.arguments[name])
|
|
440
|
+
in_axes.append(axis)
|
|
441
|
+
resolved_rank_items.append((name, sample_rank))
|
|
442
|
+
|
|
443
|
+
if validate_shapes and name in shape_specs:
|
|
444
|
+
expected_shape = _resolve_shape_spec(shape_specs[name], bound.arguments)
|
|
445
|
+
resolved_shapes[name] = expected_shape
|
|
446
|
+
if sample_shape != expected_shape:
|
|
447
|
+
raise ValueError(
|
|
448
|
+
f"Argument {name!r} has trailing sample shape {sample_shape}; "
|
|
449
|
+
f"expected {expected_shape}."
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
if size is None:
|
|
453
|
+
continue
|
|
454
|
+
if batch_size is None:
|
|
455
|
+
batch_size = size
|
|
456
|
+
elif batch_size != size:
|
|
457
|
+
raise ValueError(
|
|
458
|
+
f"Incompatible batch sizes in auto_vmap call: got {batch_size} "
|
|
459
|
+
f"and {size} while checking argument {name!r}."
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
if batch_size is None:
|
|
463
|
+
return func(*args, **kwargs)
|
|
464
|
+
|
|
465
|
+
static_arguments = {
|
|
466
|
+
name: snapshot_static_value(value)
|
|
467
|
+
for name, value in bound.arguments.items()
|
|
468
|
+
if name not in ranks
|
|
469
|
+
}
|
|
470
|
+
static_key = tuple(
|
|
471
|
+
(name, cache_key_value(value))
|
|
472
|
+
for name, value in static_arguments.items()
|
|
473
|
+
)
|
|
474
|
+
cache_key = (
|
|
475
|
+
func,
|
|
476
|
+
tuple(dynamic_names),
|
|
477
|
+
tuple(in_axes),
|
|
478
|
+
bool(jit),
|
|
479
|
+
tuple(resolved_rank_items),
|
|
480
|
+
tuple(
|
|
481
|
+
(name, resolved_shapes[name])
|
|
482
|
+
for name in dynamic_names
|
|
483
|
+
if name in resolved_shapes
|
|
484
|
+
),
|
|
485
|
+
static_key,
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
cached = lru_cache_get(_AUTO_VMAP_CACHE, cache_key)
|
|
489
|
+
if cached is None:
|
|
490
|
+
|
|
491
|
+
def mapped_call(*dynamic_args: Any) -> Any:
|
|
492
|
+
call_arguments = dict(static_arguments)
|
|
493
|
+
call_arguments.update(zip(dynamic_names, dynamic_args, strict=True))
|
|
494
|
+
return _call_with_bound_arguments(func, parameters, call_arguments)
|
|
495
|
+
|
|
496
|
+
vmapped = jax.vmap(mapped_call, in_axes=tuple(in_axes))
|
|
497
|
+
cached = jax.jit(vmapped) if jit else vmapped
|
|
498
|
+
lru_cache_set(
|
|
499
|
+
_AUTO_VMAP_CACHE,
|
|
500
|
+
cache_key,
|
|
501
|
+
cached,
|
|
502
|
+
maxsize=_AUTO_VMAP_CACHE_MAXSIZE,
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
return cached(*dynamic_values)
|
|
506
|
+
|
|
507
|
+
wrapped.auto_vmap_local_ranks = local_ranks
|
|
508
|
+
wrapped.auto_vmap_local_shapes = local_shapes
|
|
509
|
+
wrapped.auto_vmap_axes = lambda *args, **kwargs: _axes_by_call(
|
|
510
|
+
signature, parameters, local_ranks, args, kwargs
|
|
511
|
+
)
|
|
512
|
+
return wrapped
|
|
513
|
+
|
|
514
|
+
return decorator
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _axes_by_call(
|
|
518
|
+
signature: inspect.Signature,
|
|
519
|
+
parameters: Mapping[str, inspect.Parameter],
|
|
520
|
+
local_ranks: Mapping[str, int],
|
|
521
|
+
args: tuple[Any, ...],
|
|
522
|
+
kwargs: Mapping[str, Any],
|
|
523
|
+
) -> dict[str, int | None]:
|
|
524
|
+
r"""Return inferred axes for a call, primarily for debugging and tests."""
|
|
525
|
+
bound = signature.bind(*args, **kwargs)
|
|
526
|
+
bound.apply_defaults()
|
|
527
|
+
ranks = _active_ranks(parameters, local_ranks)
|
|
528
|
+
return {
|
|
529
|
+
name: _infer_vmap_axis(name, bound.arguments[name], rank)[0]
|
|
530
|
+
for name, rank in ranks.items()
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def clear_auto_vmap_caches(clear_jax: bool = True) -> None:
|
|
535
|
+
r"""
|
|
536
|
+
Clear cached wrappers created by :func:`auto_vmap`.
|
|
537
|
+
|
|
538
|
+
Args:
|
|
539
|
+
clear_jax: If ``True``, also call ``jax.clear_caches()`` when available.
|
|
540
|
+
This clears JAX's in-process compilation caches but does not remove
|
|
541
|
+
entries from a persistent on-disk compilation cache.
|
|
542
|
+
"""
|
|
543
|
+
_AUTO_VMAP_CACHE.clear()
|
|
544
|
+
if clear_jax and hasattr(jax, "clear_caches"):
|
|
545
|
+
jax.clear_caches()
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _smoke_test() -> None:
|
|
549
|
+
r"""Run a small self-contained smoke test for ``python -m stringjax_tools``."""
|
|
550
|
+
import numpy as np
|
|
551
|
+
|
|
552
|
+
@auto_vmap(moduli=1, tau=0, fluxes=1)
|
|
553
|
+
def f(moduli: Any, tau: Any, fluxes: Any, scale: float = 1.0) -> Any:
|
|
554
|
+
return scale * (jnp.sum(moduli) + tau + jnp.sum(fluxes))
|
|
555
|
+
|
|
556
|
+
single = f(jnp.array([1.0, 2.0]), jnp.array(3.0), jnp.arange(6.0), scale=2.0)
|
|
557
|
+
paired = f(jnp.ones((4, 2)), jnp.arange(4.0), jnp.ones((4, 6)), scale=2.0)
|
|
558
|
+
broadcast = f(jnp.ones((4, 2)), jnp.array(3.0), jnp.ones(6), scale=2.0)
|
|
559
|
+
|
|
560
|
+
assert np.asarray(single).shape == ()
|
|
561
|
+
np.testing.assert_allclose(np.asarray(single), 42.0)
|
|
562
|
+
assert np.asarray(paired).shape == (4,)
|
|
563
|
+
np.testing.assert_allclose(np.asarray(paired), np.array([16.0, 18.0, 20.0, 22.0]))
|
|
564
|
+
assert np.asarray(broadcast).shape == (4,)
|
|
565
|
+
np.testing.assert_allclose(np.asarray(broadcast), np.full(4, 22.0))
|
|
566
|
+
|
|
567
|
+
try:
|
|
568
|
+
f(jnp.ones((4, 2)), jnp.ones((5,)), jnp.ones((4, 6)))
|
|
569
|
+
except ValueError:
|
|
570
|
+
pass
|
|
571
|
+
else:
|
|
572
|
+
raise AssertionError("incompatible batch sizes did not raise ValueError")
|
|
573
|
+
|
|
574
|
+
try:
|
|
575
|
+
f(jnp.ones((4, 2, 1)), jnp.ones((4,)), jnp.ones((4, 6)))
|
|
576
|
+
except ValueError:
|
|
577
|
+
pass
|
|
578
|
+
else:
|
|
579
|
+
raise AssertionError("malformed moduli rank did not raise ValueError")
|
|
580
|
+
|
|
581
|
+
print("stringjax_tools smoke test passed")
|
stringjax_tools/cache.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Copyright 2022-2026 Andreas Schachner
|
|
2
|
+
#
|
|
3
|
+
# This file is part of StringJAX Tools.
|
|
4
|
+
#
|
|
5
|
+
# StringJAX Tools is free software: you can redistribute it and/or modify
|
|
6
|
+
# it under the terms of the GNU General Public License as published by
|
|
7
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
8
|
+
# (at your option) any later version.
|
|
9
|
+
#
|
|
10
|
+
# StringJAX Tools is distributed in the hope that it will be useful,
|
|
11
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
# GNU General Public License for more details.
|
|
14
|
+
#
|
|
15
|
+
# You should have received a copy of the GNU General Public License
|
|
16
|
+
# along with StringJAX Tools. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
|
|
18
|
+
"""Opt-in helpers for JAX compilation-cache configuration."""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
import jax
|
|
26
|
+
|
|
27
|
+
__all__ = ["configure_compilation_cache"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def configure_compilation_cache(
|
|
31
|
+
cache_dir: str | Path | None = None,
|
|
32
|
+
*,
|
|
33
|
+
max_size_bytes: int | None = None,
|
|
34
|
+
min_compile_time_secs: float | None = None,
|
|
35
|
+
min_entry_size_bytes: int | None = None,
|
|
36
|
+
enable_xla_caches: str | None = None,
|
|
37
|
+
explain_cache_misses: bool | None = None,
|
|
38
|
+
) -> dict[str, Any]:
|
|
39
|
+
r"""
|
|
40
|
+
Configure JAX's persistent compilation cache explicitly.
|
|
41
|
+
|
|
42
|
+
This helper has no import-time side effects. Call it near the top of a
|
|
43
|
+
script or notebook before any JAX compilation happens.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
cache_dir: Directory for the persistent compilation cache. If supplied,
|
|
47
|
+
the directory is created if needed. If ``None``, the cache
|
|
48
|
+
directory is not changed.
|
|
49
|
+
max_size_bytes: Maximum persistent cache size in bytes. JAX interprets
|
|
50
|
+
``0`` as disabling the persistent cache and ``-1`` as no size limit.
|
|
51
|
+
min_compile_time_secs: Minimum compilation time required before an entry
|
|
52
|
+
is written to the persistent cache.
|
|
53
|
+
min_entry_size_bytes: Minimum entry size required before an entry is
|
|
54
|
+
written to the persistent cache.
|
|
55
|
+
enable_xla_caches: Value forwarded to
|
|
56
|
+
``jax_persistent_cache_enable_xla_caches``.
|
|
57
|
+
explain_cache_misses: If ``True``, ask JAX to explain persistent-cache
|
|
58
|
+
misses.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Configuration names and values that were applied.
|
|
62
|
+
"""
|
|
63
|
+
updates: dict[str, Any] = {}
|
|
64
|
+
|
|
65
|
+
if cache_dir is not None:
|
|
66
|
+
cache_path = Path(cache_dir).expanduser()
|
|
67
|
+
cache_path.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
updates["jax_compilation_cache_dir"] = str(cache_path)
|
|
69
|
+
if max_size_bytes is not None:
|
|
70
|
+
updates["jax_compilation_cache_max_size"] = int(max_size_bytes)
|
|
71
|
+
if min_compile_time_secs is not None:
|
|
72
|
+
updates["jax_persistent_cache_min_compile_time_secs"] = float(
|
|
73
|
+
min_compile_time_secs
|
|
74
|
+
)
|
|
75
|
+
if min_entry_size_bytes is not None:
|
|
76
|
+
updates["jax_persistent_cache_min_entry_size_bytes"] = int(min_entry_size_bytes)
|
|
77
|
+
if enable_xla_caches is not None:
|
|
78
|
+
updates["jax_persistent_cache_enable_xla_caches"] = enable_xla_caches
|
|
79
|
+
if explain_cache_misses is not None:
|
|
80
|
+
updates["jax_explain_cache_misses"] = bool(explain_cache_misses)
|
|
81
|
+
|
|
82
|
+
for name, value in updates.items():
|
|
83
|
+
jax.config.update(name, value)
|
|
84
|
+
|
|
85
|
+
return updates
|
|
86
|
+
|