Skip to content

Commit b560ce2

Browse files
Add TransformerBlock and MLP helpers (#38)
* Use split_dims/join_dims for attention head split Raw reshape erased the static leading dims, breaking vectorize_graph and the JAX backend (which needs concrete reshape shapes); split_dims/join_dims preserve them. * Auto-register backend dispatches via a post-import hook Fires when pytensor loads a backend's dispatch package, so the JAX/MLX kernels activate at compile time with no manual import and without pulling jax/mlx onto the main import path. * Add FeedForward and TransformerBlock layers * Serialize NoneTypeT constants The grad/canonicalize rewrites in compile_train bake a None-typed constant (an unspecified SpecifyShape dim) into the graph, which save_pretrained could not serialize. * Fix GELU upcasting float32 inputs to float64 np.sqrt returns a float64 scalar; float(...) casts by value so the input dtype is preserved. * Refine types and remove mypy ignores * Restore type suppressions required by pytensor 3.2.3
1 parent e607d6d commit b560ce2

13 files changed

Lines changed: 414 additions & 21 deletions

File tree

‎pytensor_ml/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import logging
22
import sys
33

4+
import pytensor_ml.dispatch # noqa: F401 (installs the lazy backend-dispatch registration hook)
5+
46
from pytensor_ml._version import __version__
57
from pytensor_ml.checkpoint import load_state, save_state
68
from pytensor_ml.pretrained import from_pretrained, load_network, save_network, save_pretrained

‎pytensor_ml/activations.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,10 +133,12 @@ def __init__(self, approximate: bool = True):
133133

134134
def __call__(self, x: pt.TensorLike) -> pt.TensorVariable:
135135
x = pt.as_tensor(x)
136+
# float(...) keeps these Python floats: numpy's float64 scalars would upcast a float32 input to
137+
# float64, but a Python float casts by value and preserves the input dtype.
136138
if self.approximate:
137-
out = 0.5 * x * (1 + pt.tanh(np.sqrt(2.0 / np.pi) * (x + 0.044715 * x**3)))
139+
out = 0.5 * x * (1 + pt.tanh(float(np.sqrt(2.0 / np.pi)) * (x + 0.044715 * x**3)))
138140
else:
139-
out = 0.5 * x * (1 + pt.erf(x / np.sqrt(2.0)))
141+
out = 0.5 * x * (1 + pt.erf(x / float(np.sqrt(2.0))))
140142
out.name = "GELU"
141143
return out
142144

‎pytensor_ml/attention.py‎

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -183,15 +183,10 @@ def __init__(
183183
self.out_proj = Linear(f"{self.name}_out_proj", n_head * self.head_dim, n_embd, bias)
184184

185185
def _split_heads(self, x: pt.TensorVariable, n_head: int) -> pt.TensorVariable:
186-
# (..., seq, n_head * head_dim) -> (..., n_head, seq, head_dim). Keep statically known leading
187-
# dims as literals so the reshaped tensor retains its full static shape -- a symbolic
188-
# ``x.shape[i]`` would erase it. A static core shape is what lets the numba backend vectorize
189-
# attention (e.g. under ``vectorize_graph``) instead of falling back to object mode.
190-
lead = tuple(
191-
size if size is not None else x.shape[i] for i, size in enumerate(x.type.shape[:-1])
192-
)
193-
x = x.reshape((*lead, n_head, self.head_dim))
194-
return x.swapaxes(-3, -2)
186+
# (..., seq, n_head * head_dim) -> (..., n_head, seq, head_dim). split_dims keeps the static
187+
# shape a plain reshape would erase, which lets the numba backend vectorize attention (e.g. under
188+
# vectorize_graph) instead of falling back to object mode.
189+
return pt.split_dims(x, shape=(n_head, self.head_dim), axis=-1).swapaxes(-3, -2)
195190

196191
def __call__(self, x: pt.TensorLike, mask: pt.TensorLike | None = None) -> pt.TensorVariable:
197192
x = pt.as_tensor(x)
@@ -205,8 +200,7 @@ def __call__(self, x: pt.TensorLike, mask: pt.TensorLike | None = None) -> pt.Te
205200
attn = scaled_dot_product_attention(q, k, v, mask=mask, is_causal=self.is_causal)
206201

207202
# (..., n_head, seq, head_dim) -> (..., seq, n_head * head_dim)
208-
attn = attn.swapaxes(-3, -2)
209-
attn = attn.reshape((*tuple(attn.shape[:-2]), self.n_head * self.head_dim))
203+
attn = pt.join_dims(attn.swapaxes(-3, -2), start_axis=-2, n_axes=2)
210204

211205
out = self.out_proj(attn)
212206
out.name = f"{self.name}_output"

‎pytensor_ml/dispatch/__init__.py‎

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,55 @@
1-
# Per-backend funcify dispatches for pytensor_ml's marker ops. Mirroring pytensor, each backend
2-
# subpackage imports its framework at the top and registers on import, and nothing here pulls them
3-
# onto the main import path -- import pytensor_ml.dispatch.jax / .mlx to activate a backend's kernels.
1+
import importlib
2+
import importlib.abc
3+
import importlib.util
4+
import sys
5+
6+
# pytensor auto-loads its own backend dispatches because its linker imports them at compile time -- a
7+
# third-party op can't hook into that, and pytensor exposes no plugin/entry-point system. To get the same
8+
# "activate when the backend is actually used, never on the main import path" behavior, watch for pytensor
9+
# loading a backend's dispatch package and register ours right after. Installing the hook imports nothing
10+
# heavy; jax/mlx are pulled in only when (and if) that backend compiles a graph.
11+
_REGISTRATIONS = {
12+
"pytensor.link.jax.dispatch": "pytensor_ml.dispatch.jax",
13+
"pytensor.link.mlx.dispatch": "pytensor_ml.dispatch.mlx",
14+
}
15+
16+
17+
class _RegisterAfterImport(importlib.abc.MetaPathFinder):
18+
"""Import a registration module right after its target backend-dispatch package finishes loading."""
19+
20+
def find_spec(self, fullname, path=None, target=None):
21+
registration = _REGISTRATIONS.get(fullname)
22+
if registration is None:
23+
return None
24+
25+
# Resolve the real spec with ourselves out of the way to avoid infinite recursion.
26+
sys.meta_path.remove(self)
27+
try:
28+
spec = importlib.util.find_spec(fullname)
29+
finally:
30+
sys.meta_path.insert(0, self)
31+
if spec is None or spec.loader is None:
32+
return None
33+
34+
load = spec.loader.exec_module
35+
36+
def exec_module(module):
37+
load(module)
38+
_REGISTRATIONS.pop(fullname, None)
39+
if not _REGISTRATIONS and self in sys.meta_path:
40+
sys.meta_path.remove(self)
41+
importlib.import_module(registration)
42+
43+
spec.loader.exec_module = exec_module
44+
return spec
45+
46+
47+
# A backend dispatch already loaded before us (e.g. a prior compile in the same process) won't trip the
48+
# finder, so register against it immediately.
49+
for _dispatch_module, _registration in list(_REGISTRATIONS.items()):
50+
if _dispatch_module in sys.modules:
51+
_REGISTRATIONS.pop(_dispatch_module)
52+
importlib.import_module(_registration)
53+
54+
if _REGISTRATIONS:
55+
sys.meta_path.insert(0, _RegisterAfterImport())

‎pytensor_ml/json_serialize.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from pytensor.tensor.random.op import RandomVariable
1616
from pytensor.tensor.random.type import RandomGeneratorType
1717
from pytensor.tensor.type import TensorType
18+
from pytensor.tensor.type_other import NoneConst, NoneTypeT
1819

1920

2021
def type_to_json(graph_type: Type) -> dict:
@@ -24,6 +25,8 @@ def type_to_json(graph_type: Type) -> dict:
2425
return {"kind": "scalar", "dtype": graph_type.dtype}
2526
if isinstance(graph_type, RandomGeneratorType):
2627
return {"kind": "random_generator"}
28+
if isinstance(graph_type, NoneTypeT):
29+
return {"kind": "none"}
2730
raise TypeError(f"Unserializable type: {graph_type!r}")
2831

2932

@@ -34,6 +37,8 @@ def type_from_json(type_dict: dict):
3437
return ScalarType(type_dict["dtype"])
3538
if type_dict["kind"] == "random_generator":
3639
return RandomGeneratorType()
40+
if type_dict["kind"] == "none":
41+
return NoneTypeT()
3742
raise ValueError(f"Unknown type kind: {type_dict['kind']!r}")
3843

3944

@@ -76,12 +81,16 @@ def _decode_nonfinite(value):
7681

7782

7883
def const_to_json(constant: Constant) -> dict:
84+
if isinstance(constant.type, NoneTypeT):
85+
return {"type": {"kind": "none"}}
7986
value = _encode_nonfinite(np.asarray(constant.data).tolist())
8087
return {"type": type_to_json(constant.type), "value": value}
8188

8289

8390
def const_from_json(const_dict: dict):
8491
graph_type = type_from_json(const_dict["type"])
92+
if isinstance(graph_type, NoneTypeT):
93+
return NoneConst
8594
value = np.asarray(_decode_nonfinite(const_dict["value"]), dtype=graph_type.dtype)
8695
# Use the type-specific constant wrappers: a raw Constant holds an unhashable ndarray and breaks the
8796
# FrozenApply interning that reconstruction relies on.

‎pytensor_ml/optim/base.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
# An optimizer parameter is a shared tensor variable: it can index the updates dict (it is a shared variable),
1414
# take part in tensor arithmetic, and expose its concrete value via ``get_value``. Optimizer state buffers share
1515
# the same type. Aliased for readable signatures.
16-
Parameter = TensorSharedVariable
16+
type Parameter = TensorSharedVariable
1717

1818
# A mapping from a shared variable to the symbolic expression for its next value. This is pytensor's native
1919
# `updates` contract, and it is the single currency every rule and transform in this package speaks: it carries

‎pytensor_ml/transformer.py‎

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import pytensor.tensor as pt
2+
3+
from pytensor_ml.activations import GELU, Activation
4+
from pytensor_ml.attention import MultiheadAttention
5+
from pytensor_ml.layers import Dropout, Layer, LayerNorm, Linear
6+
7+
8+
def _identity(x: pt.TensorVariable) -> pt.TensorVariable:
9+
return x
10+
11+
12+
class FeedForward(Layer):
13+
r"""
14+
Position-wise feed-forward network.
15+
16+
Apply two linear layers with an activation between them, expanding to a wider hidden dimension and
17+
projecting back:
18+
19+
.. math::
20+
21+
\mathrm{FFN}(x) = \phi(x W_1 + b_1) W_2 + b_2,
22+
23+
where :math:`\phi` is the activation. This is the per-token MLP used in transformer blocks, but it is
24+
a standalone layer usable anywhere a widening-then-narrowing MLP is wanted.
25+
26+
Parameters
27+
----------
28+
name : str or None
29+
Name prefix for the layer's parameters. Defaults to "FeedForward" when None.
30+
d_model : int
31+
Input and output dimension.
32+
hidden_dim : int, optional
33+
Width of the hidden layer. Overrides ``mlp_ratio`` when given; otherwise computed as
34+
``mlp_ratio * d_model``.
35+
mlp_ratio : int, optional
36+
Hidden-to-model dimension ratio used when ``hidden_dim`` is not given. Default is 4.
37+
activation : Activation, optional
38+
Activation applied to the hidden layer. Default is :class:`GELU`.
39+
bias : bool, optional
40+
Include bias terms in both linear layers. Default is True.
41+
"""
42+
43+
def __init__(
44+
self,
45+
name: str | None,
46+
d_model: int,
47+
hidden_dim: int | None = None,
48+
mlp_ratio: int = 4,
49+
activation: Activation | None = None,
50+
bias: bool = True,
51+
):
52+
self.name = name if name else "FeedForward"
53+
self.d_model = d_model
54+
self.hidden_dim = hidden_dim if hidden_dim is not None else mlp_ratio * d_model
55+
self.activation = activation if activation is not None else GELU()
56+
57+
self.fc_in = Linear(f"{self.name}_fc_in", d_model, self.hidden_dim, bias)
58+
self.fc_out = Linear(f"{self.name}_fc_out", self.hidden_dim, d_model, bias)
59+
60+
def __call__(self, x: pt.TensorLike) -> pt.TensorVariable:
61+
x = pt.as_tensor(x)
62+
hidden = self.activation(self.fc_in(x))
63+
out = self.fc_out(hidden)
64+
out.name = f"{self.name}_output"
65+
return out
66+
67+
68+
class TransformerBlock(Layer):
69+
r"""
70+
A transformer block: multi-head self-attention and a feed-forward network, each wrapped in a
71+
residual connection around a layer-normalized sublayer.
72+
73+
With ``norm_first=True`` (pre-norm, the modern default) the block computes
74+
75+
.. math::
76+
77+
x &= x + \mathrm{Attn}(\mathrm{LN}_1(x)) \\
78+
x &= x + \mathrm{FFN}(\mathrm{LN}_2(x)),
79+
80+
and with ``norm_first=False`` (post-norm, the original formulation) the normalization is applied to
81+
the residual sum instead. Set ``is_causal=True`` for a decoder block, leave it False for an encoder
82+
block; ``n_kv_head`` enables grouped-query attention.
83+
84+
Parameters
85+
----------
86+
name : str or None
87+
Name prefix for the block's parameters. Defaults to "TransformerBlock" when None.
88+
d_model : int
89+
Model dimension of the input and output.
90+
n_head : int
91+
Number of attention heads. Must divide ``d_model`` evenly.
92+
mlp_ratio : int, optional
93+
Feed-forward hidden-to-model dimension ratio. Default is 4.
94+
activation : Activation, optional
95+
Activation used in the feed-forward network. Default is :class:`GELU`.
96+
norm_first : bool, optional
97+
Normalize each sublayer's input (pre-norm) rather than its residual sum (post-norm). Default is
98+
True.
99+
dropout : float, optional
100+
Dropout probability applied to each sublayer's output before the residual add. Dropout is omitted
101+
from the graph entirely when 0. Default is 0.0.
102+
bias : bool, optional
103+
Include bias terms in the attention and feed-forward projections. Default is True.
104+
is_causal : bool, optional
105+
Apply a causal mask in the self-attention. Default is False.
106+
n_kv_head : int, optional
107+
Number of key/value heads for grouped-query attention. Defaults to ``n_head``.
108+
epsilon : float, optional
109+
Constant added to the layer-norm variance for numerical stability. Default is 1e-5.
110+
"""
111+
112+
def __init__(
113+
self,
114+
name: str | None,
115+
d_model: int,
116+
n_head: int,
117+
*,
118+
mlp_ratio: int = 4,
119+
activation: Activation | None = None,
120+
norm_first: bool = True,
121+
dropout: float = 0.0,
122+
bias: bool = True,
123+
is_causal: bool = False,
124+
n_kv_head: int | None = None,
125+
epsilon: float = 1e-5,
126+
):
127+
self.name = name if name else "TransformerBlock"
128+
self.d_model = d_model
129+
self.norm_first = norm_first
130+
131+
self.norm1 = LayerNorm(f"{self.name}_norm1", n_in=d_model, epsilon=epsilon)
132+
self.norm2 = LayerNorm(f"{self.name}_norm2", n_in=d_model, epsilon=epsilon)
133+
self.attn = MultiheadAttention(
134+
f"{self.name}_attn",
135+
d_model,
136+
n_head,
137+
n_kv_head=n_kv_head,
138+
bias=bias,
139+
is_causal=is_causal,
140+
)
141+
self.ff = FeedForward(
142+
f"{self.name}_ff", d_model, mlp_ratio=mlp_ratio, activation=activation, bias=bias
143+
)
144+
self.attn_dropout = (
145+
Dropout(f"{self.name}_attn_dropout", p=dropout) if dropout > 0 else _identity
146+
)
147+
self.ff_dropout = (
148+
Dropout(f"{self.name}_ff_dropout", p=dropout) if dropout > 0 else _identity
149+
)
150+
151+
def __call__(self, x: pt.TensorLike, mask: pt.TensorLike | None = None) -> pt.TensorVariable:
152+
x = pt.as_tensor(x)
153+
if mask is not None:
154+
mask = pt.as_tensor(mask)
155+
156+
if self.norm_first:
157+
x = x + self.attn_dropout(self.attn(self.norm1(x), mask=mask))
158+
x = x + self.ff_dropout(self.ff(self.norm2(x)))
159+
else:
160+
x = self.norm1(x + self.attn_dropout(self.attn(x, mask=mask)))
161+
x = self.norm2(x + self.ff_dropout(self.ff(x)))
162+
163+
x.name = f"{self.name}_output" # type: ignore[union-attr]
164+
return x # type: ignore[return-value]
165+
166+
167+
__all__ = [
168+
"FeedForward",
169+
"TransformerBlock",
170+
]

‎tests/dispatch/jax/test_attention.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,12 @@ def test_additive_mask(is_causal, rng):
5353
q_sym, k_sym, v_sym, mask=pt.as_tensor(mask), is_causal=is_causal
5454
)
5555
compare_jax_and_py([q_sym, k_sym, v_sym], out, [q, k, v], assert_fn=assert_close)
56+
57+
58+
def test_dispatch_auto_registers():
59+
"""Loading JAX's dispatch must auto-register ours -- no `import pytensor_ml.dispatch.jax` needed."""
60+
from pytensor.link.jax.dispatch import jax_funcify
61+
62+
from pytensor_ml.attention import AttentionLayer
63+
64+
assert AttentionLayer in jax_funcify.registry

‎tests/dispatch/jax/test_basic.py‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,6 @@
1212

1313
jax = pytest.importorskip("jax")
1414

15-
import pytensor_ml.dispatch.jax # noqa: E402, F401 (registers pytensor_ml's JAX dispatches)
16-
1715
optimizer = RewriteDatabaseQuery(include=["jax"], exclude=JAX._optimizer.exclude)
1816
jax_mode = Mode(linker=JAXLinker(), optimizer=optimizer)
1917
py_mode = Mode(linker="py", optimizer=None)

‎tests/dispatch/mlx/test_attention.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,12 @@ def test_additive_mask(is_causal, rng):
5454
q_sym, k_sym, v_sym, mask=pt.as_tensor(mask), is_causal=is_causal
5555
)
5656
compare_mlx_and_py([q_sym, k_sym, v_sym], out, [q, k, v], assert_fn=assert_close)
57+
58+
59+
def test_dispatch_auto_registers():
60+
"""Loading MLX's dispatch must auto-register ours -- no `import pytensor_ml.dispatch.mlx` needed."""
61+
from pytensor.link.mlx.dispatch import mlx_funcify
62+
63+
from pytensor_ml.attention import AttentionLayer
64+
65+
assert AttentionLayer in mlx_funcify.registry

0 commit comments

Comments
 (0)