jarnvid 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jarnvid-0.2.1/LICENSE +21 -0
- jarnvid-0.2.1/PKG-INFO +117 -0
- jarnvid-0.2.1/README.md +96 -0
- jarnvid-0.2.1/pyproject.toml +59 -0
- jarnvid-0.2.1/pyproject.toml.orig +46 -0
- jarnvid-0.2.1/src/jarnvid/__init__.py +15 -0
- jarnvid-0.2.1/src/jarnvid/annotation.py +174 -0
- jarnvid-0.2.1/src/jarnvid/placement.py +93 -0
- jarnvid-0.2.1/src/jarnvid/py.typed +0 -0
- jarnvid-0.2.1/src/jarnvid/structure.py +95 -0
- jarnvid-0.2.1/src/jarnvid/vectorizer.py +96 -0
jarnvid-0.2.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Noah Syrkis
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
jarnvid-0.2.1/PKG-INFO
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jarnvid
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: Complex structures as frozen JAX dataclasses, made algebraic: write for one instance, vectorize over any batch.
|
|
5
|
+
Keywords: jax,vmap,vectorize,jaxtyping,pytree,dataclass,batching
|
|
6
|
+
Author: Noah Syrkis
|
|
7
|
+
Author-email: Noah Syrkis <noah@syrkis.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Dist: jax>=0.9.0
|
|
16
|
+
Requires-Dist: jaxtyping>=0.3.9
|
|
17
|
+
Requires-Python: >=3.13
|
|
18
|
+
Project-URL: Repository, https://github.com/syrkis/jarnvid
|
|
19
|
+
Project-URL: Decisions, https://github.com/syrkis/jarnvid/tree/main/docs/adr
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# jarnvid
|
|
23
|
+
|
|
24
|
+
Write a JAX function for one instance of a structure, and call it on any batch of them.
|
|
25
|
+
`vectorize` reads which axes belong to one instance from the jaxtyping annotations you
|
|
26
|
+
already write, and maps the function over the rest.
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
pip install jarnvid
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Járnviðr is the iron wood of Norse myth. The structures it is for are complex things written
|
|
33
|
+
as data (a force on a map, a doctrine, a molecule): frozen dataclasses of JAX arrays, nested in
|
|
34
|
+
tuples and named sums.
|
|
35
|
+
|
|
36
|
+
## Example
|
|
37
|
+
|
|
38
|
+
```pycon
|
|
39
|
+
>>> from dataclasses import dataclass
|
|
40
|
+
>>> from typing import Annotated
|
|
41
|
+
>>> import jax.numpy as jnp
|
|
42
|
+
>>> from jax.tree_util import register_dataclass
|
|
43
|
+
>>> from jaxtyping import Array, Float
|
|
44
|
+
>>> from jarnvid import Extend, stack, take, vectorize
|
|
45
|
+
>>> @register_dataclass
|
|
46
|
+
... @dataclass(frozen=True)
|
|
47
|
+
... class Unit:
|
|
48
|
+
... position: Float[Array, "*batch 2"]
|
|
49
|
+
... health: Float[Array, "*batch"]
|
|
50
|
+
>>> type Force = Annotated[Unit, Extend("unit")] # a side: its unit axis stays inside each call
|
|
51
|
+
>>> @vectorize
|
|
52
|
+
... def threat(force: Force, target: Float[Array, "*batch 2"]) -> Float[Array, "*batch"]:
|
|
53
|
+
... distance = jnp.linalg.norm(force.position - target, axis=-1)
|
|
54
|
+
... return (force.health / (1 + distance)).sum()
|
|
55
|
+
>>> forces = Unit(position=jnp.zeros((5, 3, 2)), health=jnp.ones((5, 3))) # 5 scenarios, 3 units each
|
|
56
|
+
>>> threat(forces, jnp.ones((5, 2))).shape # one target per scenario
|
|
57
|
+
(5,)
|
|
58
|
+
>>> threat(forces, jnp.ones(2)).shape # one target for all: it reaches every call as is
|
|
59
|
+
(5,)
|
|
60
|
+
>>> threat(stack([forces, forces]), jnp.ones((2, 5, 2))).shape # batch axes nest freely
|
|
61
|
+
(2, 5)
|
|
62
|
+
>>> take(forces, 0).position.shape # the first scenario's force
|
|
63
|
+
(3, 2)
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Why not `vmap`
|
|
68
|
+
|
|
69
|
+
`jax.vmap` maps one axis, chosen per argument with `in_axes`. Several batch axes mean nested
|
|
70
|
+
`vmap`s, and a dataclass whose fields carry different numbers of batch axes means spelling
|
|
71
|
+
out an `in_axes` tree that mirrors it. `equinox.filter_vmap` chooses what to map by type, not
|
|
72
|
+
by shape. jarnvid reads the shapes from the annotations instead:
|
|
73
|
+
|
|
74
|
+
- One function signature gives any number of batch axes, broadcast **from the left** across
|
|
75
|
+
all arguments (docs/adr/0001).
|
|
76
|
+
- A leaf with no batch axes (a map, a rule table) reaches every call once, never copied.
|
|
77
|
+
- `Extend("unit")` keeps a named axis inside each call, so a function can see a whole side.
|
|
78
|
+
- The same decorator maps in chunks (`Vectorizer(placement=Chunked(64))`, bounded memory) or
|
|
79
|
+
shards over a device mesh (`Vectorizer(placement=Sharded("data"))`, padding a batch that does
|
|
80
|
+
not divide evenly).
|
|
81
|
+
|
|
82
|
+
The price is annotation discipline: every mapped array is annotated, starting with `*batch`.
|
|
83
|
+
|
|
84
|
+
## Rules
|
|
85
|
+
|
|
86
|
+
- Arguments are PyTrees: tuples, dicts (`dict[str, T]`, `Mapping[str, T]`), registered
|
|
87
|
+
dataclasses (fields marked `static` are left alone), named sums (the arm matching the value)
|
|
88
|
+
and type aliases. Arrays in any other container are an error.
|
|
89
|
+
- What is mapped is read from the annotations alone. Plain Python values and unregistered
|
|
90
|
+
dataclasses (configs) reach every call as they are.
|
|
91
|
+
- A leaf's batch axes beyond the ones shared by all arguments stay inside each call.
|
|
92
|
+
|
|
93
|
+
Between calls, structures are reshaped by their batch axes alone (docs/adr/0002): `take`,
|
|
94
|
+
`stack`, `concat`, `expand` (singleton axes, for outer products) and `batch_shape`. Leaves
|
|
95
|
+
without batch axes stay once.
|
|
96
|
+
|
|
97
|
+
| File | Concept |
|
|
98
|
+
|---|---|
|
|
99
|
+
| `annotation.py` | Reading annotations: which trailing axes of each leaf one call sees |
|
|
100
|
+
| `vectorizer.py` | `vectorize` and `Vectorizer`: broadcast, flatten, map, reshape |
|
|
101
|
+
| `placement.py` | Where the batch runs: `Local`, `Chunked`, or `Sharded` over a mesh axis |
|
|
102
|
+
| `structure.py` | `take`, `stack`, `concat`, `expand`, `batch_shape` on batched structures |
|
|
103
|
+
|
|
104
|
+
## Develop
|
|
105
|
+
|
|
106
|
+
`just test` (the tests and this README's examples), `just sharded` (four forced CPU devices),
|
|
107
|
+
`just check` (ruff and vulture), `just build` (the wheel and sdist). The scope is fixed: one
|
|
108
|
+
decorator and the structure operations around it.
|
|
109
|
+
|
|
110
|
+
## Cite
|
|
111
|
+
|
|
112
|
+
See `CITATION.cff`. MIT licence.
|
|
113
|
+
|
|
114
|
+
## Open
|
|
115
|
+
|
|
116
|
+
- JAX 0.12: jarnvid passes unchanged. With `jax>=0.12`, add a law that `Sharded` adds no
|
|
117
|
+
collectives (`jax.sharding.no_collectives`), except for the result gather after padding.
|
jarnvid-0.2.1/README.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# jarnvid
|
|
2
|
+
|
|
3
|
+
Write a JAX function for one instance of a structure, and call it on any batch of them.
|
|
4
|
+
`vectorize` reads which axes belong to one instance from the jaxtyping annotations you
|
|
5
|
+
already write, and maps the function over the rest.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pip install jarnvid
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Járnviðr is the iron wood of Norse myth. The structures it is for are complex things written
|
|
12
|
+
as data (a force on a map, a doctrine, a molecule): frozen dataclasses of JAX arrays, nested in
|
|
13
|
+
tuples and named sums.
|
|
14
|
+
|
|
15
|
+
## Example
|
|
16
|
+
|
|
17
|
+
```pycon
|
|
18
|
+
>>> from dataclasses import dataclass
|
|
19
|
+
>>> from typing import Annotated
|
|
20
|
+
>>> import jax.numpy as jnp
|
|
21
|
+
>>> from jax.tree_util import register_dataclass
|
|
22
|
+
>>> from jaxtyping import Array, Float
|
|
23
|
+
>>> from jarnvid import Extend, stack, take, vectorize
|
|
24
|
+
>>> @register_dataclass
|
|
25
|
+
... @dataclass(frozen=True)
|
|
26
|
+
... class Unit:
|
|
27
|
+
... position: Float[Array, "*batch 2"]
|
|
28
|
+
... health: Float[Array, "*batch"]
|
|
29
|
+
>>> type Force = Annotated[Unit, Extend("unit")] # a side: its unit axis stays inside each call
|
|
30
|
+
>>> @vectorize
|
|
31
|
+
... def threat(force: Force, target: Float[Array, "*batch 2"]) -> Float[Array, "*batch"]:
|
|
32
|
+
... distance = jnp.linalg.norm(force.position - target, axis=-1)
|
|
33
|
+
... return (force.health / (1 + distance)).sum()
|
|
34
|
+
>>> forces = Unit(position=jnp.zeros((5, 3, 2)), health=jnp.ones((5, 3))) # 5 scenarios, 3 units each
|
|
35
|
+
>>> threat(forces, jnp.ones((5, 2))).shape # one target per scenario
|
|
36
|
+
(5,)
|
|
37
|
+
>>> threat(forces, jnp.ones(2)).shape # one target for all: it reaches every call as is
|
|
38
|
+
(5,)
|
|
39
|
+
>>> threat(stack([forces, forces]), jnp.ones((2, 5, 2))).shape # batch axes nest freely
|
|
40
|
+
(2, 5)
|
|
41
|
+
>>> take(forces, 0).position.shape # the first scenario's force
|
|
42
|
+
(3, 2)
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Why not `vmap`
|
|
47
|
+
|
|
48
|
+
`jax.vmap` maps one axis, chosen per argument with `in_axes`. Several batch axes mean nested
|
|
49
|
+
`vmap`s, and a dataclass whose fields carry different numbers of batch axes means spelling
|
|
50
|
+
out an `in_axes` tree that mirrors it. `equinox.filter_vmap` chooses what to map by type, not
|
|
51
|
+
by shape. jarnvid reads the shapes from the annotations instead:
|
|
52
|
+
|
|
53
|
+
- One function signature gives any number of batch axes, broadcast **from the left** across
|
|
54
|
+
all arguments (docs/adr/0001).
|
|
55
|
+
- A leaf with no batch axes (a map, a rule table) reaches every call once, never copied.
|
|
56
|
+
- `Extend("unit")` keeps a named axis inside each call, so a function can see a whole side.
|
|
57
|
+
- The same decorator maps in chunks (`Vectorizer(placement=Chunked(64))`, bounded memory) or
|
|
58
|
+
shards over a device mesh (`Vectorizer(placement=Sharded("data"))`, padding a batch that does
|
|
59
|
+
not divide evenly).
|
|
60
|
+
|
|
61
|
+
The price is annotation discipline: every mapped array is annotated, starting with `*batch`.
|
|
62
|
+
|
|
63
|
+
## Rules
|
|
64
|
+
|
|
65
|
+
- Arguments are PyTrees: tuples, dicts (`dict[str, T]`, `Mapping[str, T]`), registered
|
|
66
|
+
dataclasses (fields marked `static` are left alone), named sums (the arm matching the value)
|
|
67
|
+
and type aliases. Arrays in any other container are an error.
|
|
68
|
+
- What is mapped is read from the annotations alone. Plain Python values and unregistered
|
|
69
|
+
dataclasses (configs) reach every call as they are.
|
|
70
|
+
- A leaf's batch axes beyond the ones shared by all arguments stay inside each call.
|
|
71
|
+
|
|
72
|
+
Between calls, structures are reshaped by their batch axes alone (docs/adr/0002): `take`,
|
|
73
|
+
`stack`, `concat`, `expand` (singleton axes, for outer products) and `batch_shape`. Leaves
|
|
74
|
+
without batch axes stay once.
|
|
75
|
+
|
|
76
|
+
| File | Concept |
|
|
77
|
+
|---|---|
|
|
78
|
+
| `annotation.py` | Reading annotations: which trailing axes of each leaf one call sees |
|
|
79
|
+
| `vectorizer.py` | `vectorize` and `Vectorizer`: broadcast, flatten, map, reshape |
|
|
80
|
+
| `placement.py` | Where the batch runs: `Local`, `Chunked`, or `Sharded` over a mesh axis |
|
|
81
|
+
| `structure.py` | `take`, `stack`, `concat`, `expand`, `batch_shape` on batched structures |
|
|
82
|
+
|
|
83
|
+
## Develop
|
|
84
|
+
|
|
85
|
+
`just test` (the tests and this README's examples), `just sharded` (four forced CPU devices),
|
|
86
|
+
`just check` (ruff and vulture), `just build` (the wheel and sdist). The scope is fixed: one
|
|
87
|
+
decorator and the structure operations around it.
|
|
88
|
+
|
|
89
|
+
## Cite
|
|
90
|
+
|
|
91
|
+
See `CITATION.cff`. MIT licence.
|
|
92
|
+
|
|
93
|
+
## Open
|
|
94
|
+
|
|
95
|
+
- JAX 0.12: jarnvid passes unchanged. With `jax>=0.12`, add a law that `Sharded` adds no
|
|
96
|
+
collectives (`jax.sharding.no_collectives`), except for the result gather after padding.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "jarnvid"
|
|
3
|
+
version = "0.2.1"
|
|
4
|
+
description = "Complex structures as frozen JAX dataclasses, made algebraic: write for one instance, vectorize over any batch."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.13"
|
|
9
|
+
keywords = [
|
|
10
|
+
"jax",
|
|
11
|
+
"vmap",
|
|
12
|
+
"vectorize",
|
|
13
|
+
"jaxtyping",
|
|
14
|
+
"pytree",
|
|
15
|
+
"dataclass",
|
|
16
|
+
"batching",
|
|
17
|
+
]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 4 - Beta",
|
|
20
|
+
"Intended Audience :: Science/Research",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Topic :: Scientific/Engineering",
|
|
23
|
+
"Typing :: Typed",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"jax>=0.9.0",
|
|
27
|
+
"jaxtyping>=0.3.9",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[[project.authors]]
|
|
31
|
+
name = "Noah Syrkis"
|
|
32
|
+
email = "noah@syrkis.com"
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Repository = "https://github.com/syrkis/jarnvid"
|
|
36
|
+
Decisions = "https://github.com/syrkis/jarnvid/tree/main/docs/adr"
|
|
37
|
+
|
|
38
|
+
[dependency-groups]
|
|
39
|
+
dev = [
|
|
40
|
+
"pytest>=9.1.1",
|
|
41
|
+
"pytest-xdist",
|
|
42
|
+
"hypothesis",
|
|
43
|
+
"ruff>=0.17.4",
|
|
44
|
+
"vulture>=2.16",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
[build-system]
|
|
48
|
+
requires = ["uv_build>=0.13,<0.14"]
|
|
49
|
+
build-backend = "uv_build"
|
|
50
|
+
|
|
51
|
+
[tool.ruff]
|
|
52
|
+
line-length = 128
|
|
53
|
+
|
|
54
|
+
[tool.pytest.ini_options]
|
|
55
|
+
testpaths = [
|
|
56
|
+
"tests",
|
|
57
|
+
"README.md",
|
|
58
|
+
]
|
|
59
|
+
addopts = "-n auto --doctest-glob=README.md"
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "jarnvid"
|
|
3
|
+
version = "0.2.1"
|
|
4
|
+
description = "Complex structures as frozen JAX dataclasses, made algebraic: write for one instance, vectorize over any batch."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [{ name = "Noah Syrkis", email = "noah@syrkis.com" }]
|
|
9
|
+
requires-python = ">=3.13"
|
|
10
|
+
keywords = ["jax", "vmap", "vectorize", "jaxtyping", "pytree", "dataclass", "batching"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 4 - Beta",
|
|
13
|
+
"Intended Audience :: Science/Research",
|
|
14
|
+
"Programming Language :: Python :: 3.13",
|
|
15
|
+
"Topic :: Scientific/Engineering",
|
|
16
|
+
"Typing :: Typed",
|
|
17
|
+
]
|
|
18
|
+
dependencies = [
|
|
19
|
+
"jax>=0.9.0",
|
|
20
|
+
"jaxtyping>=0.3.9",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Repository = "https://github.com/syrkis/jarnvid"
|
|
25
|
+
Decisions = "https://github.com/syrkis/jarnvid/tree/main/docs/adr"
|
|
26
|
+
|
|
27
|
+
[dependency-groups]
|
|
28
|
+
dev = [
|
|
29
|
+
"pytest>=9.1.1",
|
|
30
|
+
"pytest-xdist",
|
|
31
|
+
"hypothesis",
|
|
32
|
+
"ruff>=0.17.4",
|
|
33
|
+
"vulture>=2.16",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[build-system]
|
|
37
|
+
requires = ["uv_build>=0.13,<0.14"]
|
|
38
|
+
build-backend = "uv_build"
|
|
39
|
+
|
|
40
|
+
[tool.ruff]
|
|
41
|
+
line-length = 128
|
|
42
|
+
|
|
43
|
+
[tool.pytest.ini_options]
|
|
44
|
+
# The README's examples run as doctests, so it cannot fall behind the code.
|
|
45
|
+
testpaths = ["tests", "README.md"]
|
|
46
|
+
addopts = "-n auto --doctest-glob=README.md"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Járnviðr, the iron wood: complex structures as data, algebraic in JAX.
|
|
2
|
+
|
|
3
|
+
Doctrine, forces, molecules: frozen dataclasses of arrays. `vectorize` reads a function's
|
|
4
|
+
jaxtyping annotations (`*batch` first), broadcasts the leading batch axes of its PyTree
|
|
5
|
+
arguments, and maps the function over them; `Extend` keeps named semantic axes inside each call;
|
|
6
|
+
`Vectorizer` maps the batch in chunks, or shards it over a mesh axis. `take`, `stack`, `concat` and `expand` reshape structures
|
|
7
|
+
along their batch axes only.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .annotation import Extend
|
|
11
|
+
from .placement import Chunked, Local, Sharded
|
|
12
|
+
from .structure import batch_shape, concat, expand, stack, take
|
|
13
|
+
from .vectorizer import Vectorizer, vectorize
|
|
14
|
+
|
|
15
|
+
__all__ = ["Chunked", "Extend", "Local", "Sharded", "Vectorizer", "batch_shape", "concat", "expand", "stack", "take", "vectorize"]
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Reading annotations: for each leaf of an argument, how many trailing axes belong to one call.
|
|
2
|
+
|
|
3
|
+
An argument's annotation is walked together with its value, once: through type aliases,
|
|
4
|
+
`Annotated[..., Extend(...)]`, the one arm of a union that admits the value, tuples, dicts and
|
|
5
|
+
dataclasses, down to jaxtyping arrays annotated `*batch ...`. The result is a PyTree of the
|
|
6
|
+
value's structure whose leaves are `Core` (the array's last `ndim` axes belong to each call; the
|
|
7
|
+
rest, at least one, are batch axes) or `Fixed` (passed to every call unchanged).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from dataclasses import dataclass, fields, is_dataclass, replace
|
|
12
|
+
from types import UnionType
|
|
13
|
+
from typing import Annotated, TypeAliasType, TypeIs, Union, get_args, get_origin, get_type_hints
|
|
14
|
+
|
|
15
|
+
from jax import tree
|
|
16
|
+
from jaxtyping import AbstractArray, PyTree
|
|
17
|
+
|
|
18
|
+
# Any runtime annotation (a class, alias, union or Annotated form); PEP 747's TypeForm, which
|
|
19
|
+
# Python 3.13 lacks, is the precise type.
|
|
20
|
+
type TypeForm = object
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True, init=False)
|
|
24
|
+
class Extend:
|
|
25
|
+
"""Insert semantic axes immediately after every nested ``*batch`` axis."""
|
|
26
|
+
|
|
27
|
+
axes: tuple[str, ...]
|
|
28
|
+
|
|
29
|
+
def __init__(self, *axes: str) -> None:
|
|
30
|
+
if not axes or any(not axis for axis in axes):
|
|
31
|
+
raise ValueError("Extend requires one or more nonempty axis names")
|
|
32
|
+
object.__setattr__(self, "axes", axes)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class Core:
|
|
37
|
+
"""An array leaf whose last `ndim` axes belong to each call; the rest are batch axes."""
|
|
38
|
+
|
|
39
|
+
ndim: int
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class Fixed:
|
|
44
|
+
"""A leaf passed to every call unchanged."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
type Rank = Core | Fixed
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def ranks(value: PyTree, annotation: TypeForm, path: str, extended: tuple[str, ...] = ()) -> PyTree:
|
|
51
|
+
"""`value`'s structure with a `Rank` at every leaf, checking each array against its annotation."""
|
|
52
|
+
annotation, axes = resolve(value, annotation, path)
|
|
53
|
+
extended += axes
|
|
54
|
+
if value is None:
|
|
55
|
+
return None
|
|
56
|
+
if is_array(annotation):
|
|
57
|
+
return array_rank(value, annotation, path, extended)
|
|
58
|
+
if isinstance(value, tuple):
|
|
59
|
+
items = zip(value, tuple_annotations(annotation, len(value), path), strict=True)
|
|
60
|
+
return tuple(ranks(item, inner, f"{path}[{index}]", extended) for index, (item, inner) in enumerate(items))
|
|
61
|
+
if isinstance(value, dict):
|
|
62
|
+
inner = mapping_annotation(annotation, path)
|
|
63
|
+
return {key: ranks(item, inner, f"{path}[{key!r}]", extended) for key, item in value.items()}
|
|
64
|
+
if is_dataclass(value):
|
|
65
|
+
return dataclass_ranks(value, annotation, path, extended)
|
|
66
|
+
if hasattr(value, "shape"):
|
|
67
|
+
raise TypeError(f"{path} must use a jaxtyping annotation beginning with '*batch'")
|
|
68
|
+
if any(hasattr(leaf, "shape") for leaf in tree.leaves(value)):
|
|
69
|
+
raise TypeError(f"{path} holds arrays in a {type(value).__name__}, which is not traversed: use a tuple, dict or dataclass")
|
|
70
|
+
return tree.map(lambda _: Fixed(), value)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def array_rank(value: PyTree, annotation: type[AbstractArray], path: str, extended: tuple[str, ...]) -> Rank:
|
|
74
|
+
"""An array's `Rank`: `Core` if it has batch axes, else `Fixed`."""
|
|
75
|
+
if not hasattr(value, "shape"):
|
|
76
|
+
raise TypeError(f"{path} is annotated as an array but received {type(value).__name__}")
|
|
77
|
+
core = core_ndim(annotation, path) + len(extended)
|
|
78
|
+
if value.ndim < core:
|
|
79
|
+
raise ValueError(f"{path} has shape {value.shape}, which is smaller than {extended_dims(annotation, extended)!r}")
|
|
80
|
+
# A leaf without batch axes reaches every call as is, never copied across the batch.
|
|
81
|
+
return Core(core) if value.ndim > core else Fixed()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def dataclass_ranks(value: PyTree, annotation: TypeForm, path: str, extended: tuple[str, ...]) -> PyTree:
|
|
85
|
+
"""A registered dataclass with the `Rank`s of its non-static fields; an unregistered one (a
|
|
86
|
+
config) is `Fixed`, unless it holds arrays."""
|
|
87
|
+
if any(leaf is value for leaf in tree.leaves(value)):
|
|
88
|
+
if any(hasattr(leaf, "shape") for leaf in tree.leaves([getattr(value, field.name) for field in fields(value)])):
|
|
89
|
+
raise TypeError(f"{path}: register {type(value).__name__} as a PyTree (jax.tree_util.register_dataclass)")
|
|
90
|
+
return Fixed()
|
|
91
|
+
if not isinstance(annotation, type) or not is_dataclass(annotation):
|
|
92
|
+
raise TypeError(f"{path} must have an annotated dataclass type")
|
|
93
|
+
hints = get_type_hints(annotation, include_extras=True)
|
|
94
|
+
mapped = (field.name for field in fields(value) if not field.metadata.get("static", False))
|
|
95
|
+
return replace(value, **{name: ranks(getattr(value, name), hints.get(name), f"{path}.{name}", extended) for name in mapped})
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def mapping_annotation(annotation: TypeForm, path: str) -> TypeForm:
|
|
99
|
+
"""The value type of a ``dict[K, V]`` or ``Mapping[K, V]`` annotation."""
|
|
100
|
+
if get_origin(annotation) not in (dict, Mapping) or len(get_args(annotation)) != 2:
|
|
101
|
+
raise TypeError(f"{path} must have an annotated dict[K, V] or Mapping[K, V] type")
|
|
102
|
+
return get_args(annotation)[1]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def resolve(value: PyTree, annotation: TypeForm, path: str) -> tuple[TypeForm, tuple[str, ...]]:
|
|
106
|
+
"""Unwrap aliases and ``Extend`` metadata, taking a union's one arm that admits ``value``."""
|
|
107
|
+
annotation, axes = extension(annotation)
|
|
108
|
+
while get_origin(annotation) in (Union, UnionType):
|
|
109
|
+
arms = tuple(arm for arm in get_args(annotation) if admits(extension(arm)[0], value))
|
|
110
|
+
if len(arms) != 1:
|
|
111
|
+
raise TypeError(f"{path} ({type(value).__name__}) matches {len(arms)} arms of {annotation}, not one")
|
|
112
|
+
annotation, arm_axes = extension(arms[0])
|
|
113
|
+
axes += arm_axes
|
|
114
|
+
return annotation, axes
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def extension(annotation: TypeForm) -> tuple[TypeForm, tuple[str, ...]]:
|
|
118
|
+
"""Separate a runtime annotation from its (possibly nested) semantic-axis metadata."""
|
|
119
|
+
axes: tuple[str, ...] = ()
|
|
120
|
+
seen: set[int] = set()
|
|
121
|
+
while True:
|
|
122
|
+
if isinstance(annotation, TypeAliasType):
|
|
123
|
+
if id(annotation) in seen:
|
|
124
|
+
raise TypeError(f"Cyclic type alias: {annotation.__name__}")
|
|
125
|
+
seen.add(id(annotation))
|
|
126
|
+
if annotation.__type_params__:
|
|
127
|
+
raise TypeError(f"Generic type aliases are not supported: {annotation.__name__}")
|
|
128
|
+
annotation = annotation.__value__
|
|
129
|
+
elif isinstance(get_origin(annotation), TypeAliasType):
|
|
130
|
+
raise TypeError(f"Generic type aliases are not supported: {annotation}")
|
|
131
|
+
elif get_origin(annotation) is Annotated:
|
|
132
|
+
annotation, *metadata = get_args(annotation)
|
|
133
|
+
axes += tuple(axis for item in metadata if isinstance(item, Extend) for axis in item.axes)
|
|
134
|
+
else:
|
|
135
|
+
return annotation, axes
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def admits(annotation: TypeForm, value: PyTree) -> bool:
|
|
139
|
+
"""Whether one union arm describes a runtime value: arrays by shape, others by class."""
|
|
140
|
+
if is_array(annotation):
|
|
141
|
+
return hasattr(value, "shape")
|
|
142
|
+
origin = get_origin(annotation) or annotation
|
|
143
|
+
return isinstance(origin, type) and isinstance(value, origin)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def tuple_annotations(annotation: TypeForm, length: int, path: str) -> tuple[TypeForm, ...]:
|
|
147
|
+
"""Expand a fixed or variadic tuple annotation over its runtime values."""
|
|
148
|
+
if get_origin(annotation) is not tuple:
|
|
149
|
+
raise TypeError(f"{path} must have an annotated tuple type")
|
|
150
|
+
annotations = get_args(annotation)
|
|
151
|
+
if len(annotations) == 2 and annotations[1] is Ellipsis:
|
|
152
|
+
return (annotations[0],) * length
|
|
153
|
+
if len(annotations) != length:
|
|
154
|
+
raise TypeError(f"{path} has {length} values but {len(annotations)} annotated tuple fields")
|
|
155
|
+
return annotations
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def is_array(annotation: TypeForm) -> TypeIs[type[AbstractArray]]:
|
|
159
|
+
"""Whether an annotation is a concrete jaxtyping array type."""
|
|
160
|
+
return isinstance(annotation, type) and issubclass(annotation, AbstractArray)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def core_ndim(annotation: type[AbstractArray], path: str) -> int:
|
|
164
|
+
"""The axes to the right of the required leading ``*batch``."""
|
|
165
|
+
dimensions = annotation.dim_str.split()
|
|
166
|
+
if annotation.index_variadic != 0 or not dimensions or dimensions[0] != "*batch":
|
|
167
|
+
raise TypeError(f"{path} must use a jaxtyping annotation beginning with '*batch'")
|
|
168
|
+
return len(annotation.dims) - 1
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def extended_dims(annotation: type[AbstractArray], extended: tuple[str, ...]) -> str:
|
|
172
|
+
"""The effective annotation, semantic axes included, for error messages."""
|
|
173
|
+
batch, *core = annotation.dim_str.split()
|
|
174
|
+
return " ".join((batch, *extended, *core))
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Where the flattened batch runs: on the local devices, or split over one mesh axis."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Protocol, assert_never
|
|
5
|
+
|
|
6
|
+
import jax
|
|
7
|
+
import jax.numpy as jnp
|
|
8
|
+
from jax import tree
|
|
9
|
+
from jax.sharding import NamedSharding, PartitionSpec
|
|
10
|
+
from jaxtyping import PyTree
|
|
11
|
+
|
|
12
|
+
from .annotation import Core
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, slots=True)
|
|
16
|
+
class Local:
|
|
17
|
+
"""Map the flattened batch on the current devices."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class Chunked:
|
|
22
|
+
"""Map the flattened batch `batch` instances at a time (``lax.map``): the same result as
|
|
23
|
+
``Local`` in bounded memory."""
|
|
24
|
+
|
|
25
|
+
batch: int
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class Sharded:
|
|
30
|
+
"""Split the one flattened batch axis over the active mesh's ``axis``."""
|
|
31
|
+
|
|
32
|
+
axis: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
type Placement = Local | Chunked | Sharded
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Apply[R](Protocol):
|
|
39
|
+
"""One call of the wrapped function, on the mapped arguments by name."""
|
|
40
|
+
|
|
41
|
+
def __call__(self, values: dict[str, PyTree], /) -> R: ...
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def run[R](placement: Placement, apply: Apply[R], values: dict[str, PyTree], ranks: dict[str, PyTree], size: int) -> R:
|
|
45
|
+
"""`apply` mapped over the leading axis (of length `size`) of every `Core` leaf of `values`."""
|
|
46
|
+
axes = tree.map(lambda rank: 0 if isinstance(rank, Core) else None, ranks)
|
|
47
|
+
mapped = jax.vmap(apply, in_axes=(axes,))
|
|
48
|
+
if size == 0:
|
|
49
|
+
return mapped(values)
|
|
50
|
+
match placement:
|
|
51
|
+
case Local():
|
|
52
|
+
return mapped(values)
|
|
53
|
+
case Chunked(batch=batch):
|
|
54
|
+
|
|
55
|
+
def at(index: jax.Array) -> R:
|
|
56
|
+
return apply(tree.map(lambda leaf, rank: leaf[index] if isinstance(rank, Core) else leaf, values, ranks))
|
|
57
|
+
|
|
58
|
+
return jax.lax.map(at, jnp.arange(size), batch_size=batch)
|
|
59
|
+
case Sharded(axis=axis):
|
|
60
|
+
return sharded(axis, mapped, values, ranks, size)
|
|
61
|
+
case _:
|
|
62
|
+
assert_never(placement)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def sharded[R](axis: str, mapped: Apply[R], values: dict[str, PyTree], ranks: dict[str, PyTree], size: int) -> R:
|
|
66
|
+
"""`mapped` over the batch split across the active mesh's `axis`.
|
|
67
|
+
|
|
68
|
+
The batch is padded to a multiple of the axis with copies of its last instance, and the
|
|
69
|
+
padding cut from a replicated result (a sharded axis cannot be cut unevenly). Unbatched
|
|
70
|
+
arrays are replicated: shard_map cannot close over arrays sharded on Explicit axes.
|
|
71
|
+
"""
|
|
72
|
+
mesh = jax.sharding.get_abstract_mesh()
|
|
73
|
+
if axis not in mesh.shape:
|
|
74
|
+
raise ValueError(f"vectorize over Sharded({axis!r}) requires an active mesh with that axis")
|
|
75
|
+
padding = -size % mesh.shape[axis]
|
|
76
|
+
|
|
77
|
+
def spec(leaf: PyTree, rank: PyTree) -> PartitionSpec | None:
|
|
78
|
+
if not hasattr(leaf, "shape"):
|
|
79
|
+
return None
|
|
80
|
+
return PartitionSpec(axis) if isinstance(rank, Core) else PartitionSpec()
|
|
81
|
+
|
|
82
|
+
def place(leaf: PyTree, rank: PyTree) -> PyTree:
|
|
83
|
+
if not hasattr(leaf, "shape"):
|
|
84
|
+
return leaf
|
|
85
|
+
if isinstance(rank, Core):
|
|
86
|
+
leaf = jnp.pad(leaf, [(0, padding)] + [(0, 0)] * (leaf.ndim - 1), mode="edge")
|
|
87
|
+
return jax.sharding.reshard(leaf, NamedSharding(mesh, spec(leaf, rank)))
|
|
88
|
+
|
|
89
|
+
local = jax.shard_map(mapped, mesh=mesh, in_specs=(tree.map(spec, values, ranks),), out_specs=PartitionSpec(axis), check_vma=False)
|
|
90
|
+
result = local(tree.map(place, values, ranks))
|
|
91
|
+
if not padding:
|
|
92
|
+
return result
|
|
93
|
+
return tree.map(lambda leaf: jax.sharding.reshard(leaf, PartitionSpec())[:size], result)
|
|
File without changes
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Batch-aware operations on structures: `take`, `stack`, `concat`, `expand`, `batch_shape`.
|
|
2
|
+
|
|
3
|
+
A structure is a registered dataclass whose annotations say which axes of each leaf are batch
|
|
4
|
+
axes, as `vectorize` reads them. Only leaves with batch axes move: a leaf without them (a terrain
|
|
5
|
+
kept once) stays once, and plain values (Python scalars) must agree.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from typing import Protocol
|
|
10
|
+
|
|
11
|
+
import jax.numpy as jnp
|
|
12
|
+
from jax import Array, tree
|
|
13
|
+
from jax.typing import ArrayLike
|
|
14
|
+
from jaxtyping import PyTree
|
|
15
|
+
|
|
16
|
+
from .annotation import Core, Fixed, Rank, ranks
|
|
17
|
+
from .vectorizer import batch_shapes, shared
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def layout(value: PyTree) -> PyTree:
|
|
21
|
+
"""`value`'s `Rank`s, read from its own class's annotations."""
|
|
22
|
+
return ranks(value, type(value), type(value).__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def batch_shape(value: PyTree) -> tuple[int, ...]:
|
|
26
|
+
"""The batch `vectorize` maps `value` over."""
|
|
27
|
+
return shared(batch_shapes(type(value).__name__, value, layout(value)))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def take[T](value: T, index: ArrayLike) -> T:
|
|
31
|
+
"""The instance (or instances) at `index` along the leading batch axis."""
|
|
32
|
+
size = leading(value)
|
|
33
|
+
return tree.map(lambda leaf, rank: lead(leaf, rank, size)[index] if isinstance(rank, Core) else leaf, value, layout(value))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def stack[T](values: Sequence[T]) -> T:
|
|
37
|
+
"""`values` along a new leading batch axis."""
|
|
38
|
+
return merge(values, lambda leaves, _: jnp.stack(jnp.broadcast_arrays(*leaves)))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def concat[T](values: Sequence[T]) -> T:
|
|
42
|
+
"""`values` joined along their leading batch axis."""
|
|
43
|
+
sizes = tuple(map(leading, values))
|
|
44
|
+
return merge(values, lambda leaves, ranks: jnp.concatenate([lead(*parts) for parts in zip(leaves, ranks, sizes, strict=True)]))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def expand[T](value: T, before: int = 0, after: int = 0) -> T:
|
|
48
|
+
"""`value` with singleton batch axes before and after its batch axes; an outer product is
|
|
49
|
+
`f(expand(xs, after=1), expand(ys, before=1))`."""
|
|
50
|
+
rank = len(batch_shape(value))
|
|
51
|
+
|
|
52
|
+
def insert(leaf: PyTree, r: PyTree) -> PyTree:
|
|
53
|
+
if not isinstance(r, Core):
|
|
54
|
+
return leaf
|
|
55
|
+
return leaf.reshape((1,) * before + leaf.shape[:rank] + (1,) * after + leaf.shape[rank:])
|
|
56
|
+
|
|
57
|
+
return tree.map(insert, value, layout(value))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Join(Protocol):
|
|
61
|
+
"""Combine one leaf from each structure into one leaf."""
|
|
62
|
+
|
|
63
|
+
def __call__(self, leaves: tuple[Array, ...], ranks: tuple[Rank, ...], /) -> Array: ...
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def merge[T](values: Sequence[T], join: Join) -> T:
|
|
67
|
+
"""Leaf by leaf: agreeing plain values, and one leaf without batch axes that every structure
|
|
68
|
+
shares, stay once; the rest are `join`ed."""
|
|
69
|
+
count = len(values)
|
|
70
|
+
|
|
71
|
+
def leaf(*items: PyTree) -> PyTree:
|
|
72
|
+
leaves, rank = items[:count], items[count:]
|
|
73
|
+
if not all(hasattr(item, "shape") for item in leaves):
|
|
74
|
+
if any(item != leaves[0] for item in leaves):
|
|
75
|
+
raise ValueError(f"Plain values differ across the structures: {leaves}")
|
|
76
|
+
return leaves[0]
|
|
77
|
+
if all(isinstance(r, Fixed) and item is leaves[0] for item, r in zip(leaves, rank, strict=True)):
|
|
78
|
+
return leaves[0]
|
|
79
|
+
return join(leaves, rank)
|
|
80
|
+
|
|
81
|
+
return tree.map(leaf, *values, *map(layout, values))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def leading(value: PyTree) -> int:
|
|
85
|
+
"""The size of `value`'s leading batch axis."""
|
|
86
|
+
shape = batch_shape(value)
|
|
87
|
+
if not shape:
|
|
88
|
+
raise ValueError(f"{type(value).__name__} has no batch axes")
|
|
89
|
+
return shape[0]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def lead(leaf: Array, rank: Rank, size: int) -> Array:
|
|
93
|
+
"""`leaf` with a leading batch axis of `size`, broadcast if it has one, added if not."""
|
|
94
|
+
shape = leaf.shape[1:] if isinstance(rank, Core) else leaf.shape
|
|
95
|
+
return jnp.broadcast_to(leaf, (size, *shape))
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""`vectorize`: write a function for one instance, call it on any batch.
|
|
2
|
+
|
|
3
|
+
Each argument's annotation gives, for every array leaf, the trailing axes one call sees; the
|
|
4
|
+
leading axes left over are batch axes. Batch axes broadcast across leaves **aligned from the
|
|
5
|
+
left**: the shared batch is the broadcast of every leaf's first `r` batch axes, `r` the fewest
|
|
6
|
+
any leaf has, and a leaf's further batch axes stay inside each call (docs/adr/0001). The batch
|
|
7
|
+
is flattened to one axis, mapped (`placement`), and reshaped back.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from functools import wraps
|
|
13
|
+
from inspect import signature
|
|
14
|
+
from math import prod
|
|
15
|
+
from typing import get_type_hints
|
|
16
|
+
|
|
17
|
+
import jax.numpy as jnp
|
|
18
|
+
from jax import Array, tree
|
|
19
|
+
from jax.tree_util import keystr
|
|
20
|
+
from jaxtyping import PyTree
|
|
21
|
+
|
|
22
|
+
from .annotation import Core, ranks
|
|
23
|
+
from .placement import Local, Placement, run
|
|
24
|
+
|
|
25
|
+
# A function whose signature vectorize preserves.
|
|
26
|
+
type Function[**P, R] = Callable[P, R]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def vectorize[**P, R](fn: Function[P, R]) -> Function[P, R]:
|
|
30
|
+
"""Vectorize a PyTree function from its jaxtyping annotations."""
|
|
31
|
+
return Vectorizer()(fn)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class Vectorizer:
|
|
36
|
+
"""A configured ``vectorize``: where the batch runs. What is mapped is read from the
|
|
37
|
+
annotations alone; `Extend` keeps named axes inside each call."""
|
|
38
|
+
|
|
39
|
+
placement: Placement = field(default_factory=Local)
|
|
40
|
+
|
|
41
|
+
def __call__[**P, R](self, fn: Function[P, R]) -> Function[P, R]:
|
|
42
|
+
call = signature(fn)
|
|
43
|
+
hints = get_type_hints(fn, include_extras=True)
|
|
44
|
+
|
|
45
|
+
@wraps(fn)
|
|
46
|
+
def wrapped(*args: P.args, **kwargs: P.kwargs) -> R:
|
|
47
|
+
bound = call.bind(*args, **kwargs)
|
|
48
|
+
bound.apply_defaults()
|
|
49
|
+
arguments = bound.arguments
|
|
50
|
+
layout = {name: ranks(arguments[name], hints.get(name), name) for name in arguments}
|
|
51
|
+
batches = {name: batch_shapes(name, arguments[name], rank) for name, rank in layout.items()}
|
|
52
|
+
batch = shared({path: shape for shapes in batches.values() for path, shape in shapes.items()})
|
|
53
|
+
if not batch:
|
|
54
|
+
return fn(*args, **kwargs)
|
|
55
|
+
values = {name: tree.map(lambda leaf, r: flatten(leaf, r, batch), arguments[name], rank) for name, rank in layout.items()}
|
|
56
|
+
|
|
57
|
+
def apply(inner: dict[str, PyTree]) -> R:
|
|
58
|
+
one = call.bind_partial()
|
|
59
|
+
one.arguments.update(arguments | inner)
|
|
60
|
+
return fn(*one.args, **one.kwargs)
|
|
61
|
+
|
|
62
|
+
result = run(self.placement, apply, values, layout, prod(batch))
|
|
63
|
+
return tree.map(lambda leaf: leaf.reshape(batch + leaf.shape[1:]), result)
|
|
64
|
+
|
|
65
|
+
return wrapped
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def batch_shapes(name: str, value: PyTree, rank: PyTree) -> dict[str, tuple[int, ...]]:
|
|
69
|
+
"""The batch shape (axes before the core) of every batched array leaf, by path."""
|
|
70
|
+
pairs = zip(tree.leaves_with_path(value), tree.leaves(rank), strict=True)
|
|
71
|
+
return {name + keystr(path): leaf.shape[: leaf.ndim - r.ndim] for (path, leaf), r in pairs if isinstance(r, Core)}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def shared(shapes: dict[str, tuple[int, ...]]) -> tuple[int, ...]:
|
|
75
|
+
"""The broadcast of every batched leaf's first `r` batch axes, `r` the fewest any has."""
|
|
76
|
+
if not shapes:
|
|
77
|
+
return ()
|
|
78
|
+
rank = min(map(len, shapes.values()))
|
|
79
|
+
try:
|
|
80
|
+
return jnp.broadcast_shapes(*(shape[:rank] for shape in shapes.values()))
|
|
81
|
+
except ValueError:
|
|
82
|
+
leaves = ", ".join(f"{path} {shape}" for path, shape in shapes.items())
|
|
83
|
+
raise ValueError(
|
|
84
|
+
f"Batch axes do not broadcast in their first {rank}, aligned from the left: {leaves}. "
|
|
85
|
+
"An axis meant to stay inside each call belongs in the annotation, or in Extend."
|
|
86
|
+
) from None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def flatten(leaf: PyTree, rank: PyTree, batch: tuple[int, ...]) -> PyTree:
|
|
90
|
+
"""A `Core` leaf broadcast to the shared batch, which is flattened to one leading axis; a
|
|
91
|
+
leaf's batch axes beyond the shared ones stay inside the call."""
|
|
92
|
+
if not isinstance(rank, Core):
|
|
93
|
+
return jnp.asarray(leaf) if hasattr(leaf, "shape") else leaf
|
|
94
|
+
array: Array = leaf
|
|
95
|
+
rest = array.shape[len(batch) : array.ndim - rank.ndim] + array.shape[array.ndim - rank.ndim :]
|
|
96
|
+
return jnp.broadcast_to(array, batch + rest).reshape((prod(batch), *rest))
|