Skip to content

Commit 57afee1

Browse files
Added support for sharing layers between different parts of a model.
1 parent 56cc31f commit 57afee1

5 files changed

Lines changed: 292 additions & 0 deletions

File tree

docs/api/nn/shared.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Sharing layers
2+
3+
::: equinox.nn.Shared
4+
selection:
5+
members:
6+
- __init__
7+
- __call__

equinox/nn/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
Sequential as Sequential,
4040
StatefulLayer as StatefulLayer,
4141
)
42+
from ._shared import Shared as Shared
4243
from ._spectral_norm import SpectralNorm as SpectralNorm
4344
from ._stateful import (
4445
delete_init_state as delete_init_state,

equinox/nn/_shared.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
from collections.abc import Callable
2+
3+
from jaxtyping import PyTree
4+
5+
from .._eval_shape import filter_eval_shape
6+
from .._module import Module
7+
from .._tree import tree_at, tree_equal
8+
9+
10+
class SharedNode:
11+
"""Placeholder value for nodes that have been removed by `eqx.nn.Shared`."""
12+
13+
def __repr__(self):
14+
return "SharedNode"
15+
16+
17+
class Shared(Module):
18+
"""Used to tie together multiple nodes across a PyTree.
19+
20+
Note that Equinox modules are Py**Trees** -- so the same layer, appearing in two
21+
difference parts of the tree, will be treated as two copies of this layer. For
22+
example,
23+
```python
24+
class SubModel(eqx.Module):
25+
linear: eqx.nn.Linear
26+
27+
class Model(eqx.Module):
28+
linear: eqx.nn.Linear
29+
submodel: SubModel
30+
31+
def __init__(self):
32+
linear = eqx.nn.Linear(...)
33+
self.linear = linear
34+
self.submodel = SubModel(linear)
35+
```
36+
is used to declare `model.linear` and `model.submodel.linear` as two separate
37+
layers. They will start with the same initial parameter values, and then update
38+
independently during training.
39+
40+
For when we really do want to share layers or weights across different parts of a
41+
model, then `eqx.nn.Shared` exists as a way to easily express this in the PyTree
42+
paradigm.
43+
44+
!!! Example
45+
46+
It is common in many language models to have an initial embedding matrix at the
47+
start, and then to reuse this as the weight of the final linear transformation.
48+
49+
```python
50+
import equinox as eqx
51+
import jax.numpy as jnp
52+
from jaxtyping import Array, Int
53+
54+
class LanguageModel(eqx.Module):
55+
shared: eqx.nn.Shared
56+
57+
def __init__(self):
58+
embedding = eqx.nn.Embedding(...)
59+
linear = eqx.nn.Linear(...)
60+
# These two weights will now be tied together.
61+
where = lambda embed_and_lin: embed_and_lin[1].weight
62+
get = lambda embed_and_lin: embed_and_lin[0].weight
63+
self.shared = eqx.nn.Shared((embedding, linear), where, get)
64+
65+
def __call__(self, tokens: Int[Array, "sequence"]):
66+
# Expand back out so we can evaluate these layers.
67+
embedding, linear = self.shared()
68+
assert embedding.weight is linear.weight # same parameter!
69+
# Now go ahead and evaluate your language model.
70+
values = jax.vmap(embedding)(tokens)
71+
... # other layers, probably
72+
return jax.vmap(linear)(values)
73+
```
74+
75+
_(Side note: you will sometimes see some authors referring to transposing
76+
the embedding matrix prior to the final linear layer. This is because some
77+
other libraries store the weight matrices of linear layers the other way
78+
around. If that had been necessary here then we could have done it with
79+
`get = lambda embed_and_lin: jnp.transpose(embed_and_lin[0].weight)`.)_
80+
81+
"""
82+
83+
pytree: PyTree
84+
where: Callable
85+
get: Callable
86+
87+
def __init__(self, pytree: PyTree, where: Callable, get: Callable):
88+
"""**Arguments:**
89+
90+
- `pytree`: The PyTree to share some nodes across.
91+
- `where`: a function specifying either a single node, or a sequence of nodes,
92+
as with `eqx.tree_at(where, pytree, ...)`.
93+
- `get`: a function, which when evaluated on `pytree`, returns either a single
94+
value (if `where` does), or a sequence of values (if `where` does, and in
95+
this case this must be a sequence of the same length as `where`).
96+
97+
The node(s) of `get(pytree)` and the corresponding value(s) of `where(pytree)`
98+
will be tied together.
99+
100+
!!! info
101+
102+
To explain how this works. The implementation is just:
103+
```python
104+
class Shared(eqx.Module):
105+
pytree: PyTree
106+
where: Callable
107+
get: Callable
108+
109+
def __init__(self, pytree, where, get):
110+
# `0` is just some dummy value
111+
self.pytree = eqx.tree_at(where, pytree, replace_fn=lambda _: 0)
112+
self.where = where
113+
self.get = get
114+
115+
def __call__(self):
116+
return eqx.tree_at(self.where, self.pytree, self.get(self.pytree))
117+
```
118+
so that at `__init__` time, the duplicate nodes specified in `where` are
119+
removed from the PyTree. We no longer have a separate copy updating during
120+
training.
121+
122+
And then at `__call__` time, references to the values returned by
123+
`get(pytree)` are put in their place. We end up with a pytree of the same
124+
structure as what we started with, which we can now use (evaluate as a
125+
layer etc.) as normal.
126+
127+
!!! tip
128+
129+
If you need to apply any transform (e.g. transposing a matrix), then this
130+
can be done as part of `get`. For example,
131+
`get = lambda pair: jnp.transpose(pair[1].weight)`.
132+
"""
133+
134+
source_struct = filter_eval_shape(get, pytree)
135+
dest_struct = filter_eval_shape(where, pytree)
136+
if tree_equal(source_struct, dest_struct) is not True:
137+
raise ValueError(
138+
"Every node being shared together must have the same pytree "
139+
"structure, shape+dtype of arrays, etc., as each other. Got:\n"
140+
f"{source_struct}\n"
141+
"and\n"
142+
f"{dest_struct}"
143+
)
144+
self.pytree = tree_at(where, pytree, replace_fn=lambda _: SharedNode())
145+
self.where = where
146+
self.get = get
147+
148+
def __call__(self):
149+
"""**Arguments:**
150+
151+
None.
152+
153+
**Returns:**
154+
155+
A PyTree of the same structure as the original `pytree`, with `get(pytree)` in
156+
the place of the nodes at `where(pytree)`.
157+
"""
158+
return tree_at(self.where, self.pytree, self.get(self.pytree))

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ nav:
128128
- 'api/nn/mlp.md'
129129
- 'api/nn/sequential.md'
130130
- 'api/nn/inference.md'
131+
- 'api/nn/shared.md'
131132
- 'api/nn/stateful.md'
132133
- Filtering:
133134
- 'api/filtering/partition-combine.md'

tests/test_shared.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import jax
2+
import jax.numpy as jnp
3+
import jax.random as jr
4+
import pytest
5+
from jaxtyping import Array, Float, Int
6+
7+
import equinox as eqx
8+
9+
10+
def test_shared_array(getkey):
11+
class MyModule(eqx.Module):
12+
shared: eqx.nn.Shared
13+
14+
def __init__(self):
15+
embedding = eqx.nn.Embedding(
16+
num_embeddings=3, embedding_size=4, key=getkey()
17+
)
18+
head = eqx.nn.Linear(4, 3, key=getkey())
19+
where = lambda pair: pair[1].weight
20+
get = lambda pair: pair[0].weight
21+
self.shared = eqx.nn.Shared((embedding, head), where, get)
22+
23+
def __call__(self, token: Int[Array, ""]):
24+
nonlocal called
25+
called = True
26+
embedding, head = self.shared()
27+
assert embedding.weight is head.weight
28+
return head(embedding(token))
29+
30+
called = False
31+
module = MyModule()
32+
module(jnp.array(0))
33+
assert called
34+
35+
36+
# We share a non-leaf node
37+
def test_shared_node(getkey):
38+
class MyModule(eqx.Module):
39+
shared: eqx.nn.Shared
40+
41+
def __init__(self):
42+
attention = eqx.nn.MultiheadAttention(
43+
num_heads=3, query_size=12, key=getkey()
44+
)
45+
my_proj = eqx.nn.Linear(12, 12, use_bias=False, key=getkey())
46+
where = lambda pair: pair[1].key_proj
47+
get = lambda pair: pair[0]
48+
self.shared = eqx.nn.Shared((my_proj, attention), where, get)
49+
50+
def __call__(self, x: Float[Array, "seq 12"]):
51+
nonlocal called
52+
called = True
53+
my_proj, attention = self.shared()
54+
eq = eqx.tree_equal(my_proj, attention.key_proj)
55+
x = attention(x, x, x)
56+
out = jax.vmap(my_proj)(x)
57+
return out, eq
58+
59+
called = False
60+
module = MyModule()
61+
x = jr.normal(getkey(), (5, 12))
62+
63+
@eqx.filter_jit
64+
@eqx.filter_grad(has_aux=True)
65+
def f(module, x):
66+
out, eq = module(x)
67+
return jnp.sum(out), eq
68+
69+
d_module, eq = f(module, x)
70+
assert called
71+
assert eq
72+
module = eqx.apply_updates(module, d_module)
73+
d_module, eq = f(module, x)
74+
assert eq
75+
module = eqx.apply_updates(module, d_module)
76+
77+
78+
def test_mismatched_structure(getkey):
79+
x = jr.normal(getkey(), (3, 4))
80+
y = jr.normal(getkey(), (4, 3))
81+
with pytest.raises(ValueError, match="Every node being shared together"):
82+
eqx.nn.Shared((x, y), lambda pair: pair[0], lambda pair: pair[1])
83+
84+
85+
def test_multi_shared(getkey):
86+
class MyModule(eqx.Module):
87+
shared: eqx.nn.Shared
88+
89+
def __init__(self):
90+
my_proj = eqx.nn.Linear(12, 12, use_bias=False, key=getkey())
91+
attention = eqx.nn.MultiheadAttention(
92+
num_heads=3, query_size=12, key=getkey()
93+
)
94+
where = lambda pair: (pair[1].key_proj, pair[1].query_proj.weight)
95+
get = lambda pair: (pair[0], pair[0].weight + 1)
96+
self.shared = eqx.nn.Shared((my_proj, attention), where, get)
97+
98+
def __call__(self, x: Float[Array, "seq 12"]):
99+
nonlocal called
100+
called = True
101+
my_proj, attention = self.shared()
102+
eq1 = eqx.tree_equal(my_proj, attention.key_proj)
103+
eq2 = (my_proj.weight + 1 == attention.query_proj.weight).all()
104+
x = attention(x, x, x)
105+
out = jax.vmap(my_proj)(x)
106+
eq = eq1 & eq2
107+
return out, eq
108+
109+
called = False
110+
module = MyModule()
111+
x = jr.normal(getkey(), (5, 12))
112+
113+
@eqx.filter_jit
114+
@eqx.filter_grad(has_aux=True)
115+
def f(module, x):
116+
out, eq = module(x)
117+
return jnp.sum(out), eq
118+
119+
d_module, eq = f(module, x)
120+
assert called
121+
assert eq
122+
module = eqx.apply_updates(module, d_module)
123+
d_module, eq = f(module, x)
124+
assert eq
125+
module = eqx.apply_updates(module, d_module)

0 commit comments

Comments
 (0)