Skip to content

Commit 66126cb

Browse files
authored
Merge pull request #24 from abhijeetgangan/ag/oeq_integration
Add openequivariance support for JAX
2 parents 1fb985d + ead1dba commit 66126cb

16 files changed

Lines changed: 255 additions & 42 deletions

README.md

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,15 @@ Model | Dataset | Theory | Reference
1919
pip install nequix
2020
```
2121

22-
or for torch
22+
to use [OpenEquivariance](https://github.com/PASSIONLab/OpenEquivariance) kernels,
23+
24+
```bash
25+
pip install nequix[oeq]
26+
# needs to be run after installation:
27+
uv pip install openequivariance_extjax --no-build-isolation
28+
```
29+
30+
or for torch (also with kernels):
2331

2432
```bash
2533
pip install nequix[torch]
@@ -37,14 +45,16 @@ atoms = ...
3745
atoms.calc = NequixCalculator("nequix-mp-1", backend="jax")
3846
```
3947

40-
or if you want to use the faster PyTorch + kernels backend
48+
or if you want to use the torch backend:
4149

4250
```python
4351
...
4452
atoms.calc = NequixCalculator("nequix-mp-1", backend="torch")
4553
...
4654
```
4755

56+
These are typically comparable in speed with kernels.
57+
4858
#### NequixCalculator
4959

5060
Arguments
@@ -53,7 +63,7 @@ Arguments
5363
- `backend` ({"jax", "torch"}, default "jax"): Compute backend.
5464
- `capacity_multiplier` (float, default 1.1): JAX-only; padding factor to limit recompiles.
5565
- `use_compile` (bool, default True): Torch-only; on GPU, uses `torch.compile()`.
56-
- `use_kernel` (bool, default True): Torch-only; on GPU, use [OpenEquivariance](https://github.com/PASSIONLab/OpenEquivariance) kernels.
66+
- `use_kernel` (bool, default True): on GPU, use [OpenEquivariance](https://github.com/PASSIONLab/OpenEquivariance) kernels.
5767

5868
### Training
5969

@@ -100,7 +110,7 @@ Then start the training run:
100110
nequix_train configs/nequix-mp-1.yml
101111
```
102112

103-
This will take less than 125 hours on a single 4 x A100 node (<25 hours using the torch + kernels backend). The `batch_size` in the
113+
This will take less than 125 hours on a single 4 x A100 node (<25 hours with kernels). The `batch_size` in the
104114
config is per-device, so you should be able to run this on any number of GPUs
105115
(although hyperparameters like learning rate are often sensitive to global batch
106116
size, so keep in mind).

configs/nequix-mp-1-pft-no-cotrain.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,4 @@ hessian_weight: 100.0
3737
val_every: 2
3838
log_every: 100
3939
ema_decay: 0.999
40+
kernel: true

configs/nequix-mp-1-pft.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,4 @@ hessian_weight: 100.0
3636
val_every: 2
3737
log_every: 100
3838
ema_decay: 0.999
39+
kernel: true

configs/nequix-oam-1-pft.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,4 @@ hessian_weight: 100.0
4848
val_every: 5
4949
log_every: 100
5050
ema_decay: 0.999
51+
kernel: true

nequix/calculator.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def __init__(
4545
model_path: str = None,
4646
capacity_multiplier: float = 1.1, # Only for jax backend
4747
backend: str = "jax",
48-
use_kernel: bool = True, # Only for torch backend
48+
use_kernel: bool = True,
4949
use_compile: bool = True, # Only for torch backend
5050
**kwargs,
5151
):
@@ -75,7 +75,7 @@ def __init__(
7575
path_backend = "jax" if model_path.suffix == ".nqx" else "torch"
7676
if path_backend == backend:
7777
if backend == "jax":
78-
self.model, self.config = load_model_jax(model_path)
78+
self.model, self.config = load_model_jax(model_path, use_kernel)
7979
else:
8080
from nequix.torch.model import load_model as load_model_torch
8181

@@ -87,13 +87,15 @@ def __init__(
8787

8888
torch_model, torch_config = load_model_torch(model_path, use_kernel)
8989
print("Converting PyTorch model to JAX ...")
90-
self.model, self.config = convert_model_torch_to_jax(torch_model, torch_config)
90+
self.model, self.config = convert_model_torch_to_jax(
91+
torch_model, torch_config, use_kernel
92+
)
9193
out_path = model_path.parent / f"{model_name}.nqx"
9294
save_model_jax(out_path, self.model, self.config)
9395
else:
9496
from nequix.torch.utils import convert_model_jax_to_torch
9597

96-
jax_model, jax_config = load_model_jax(model_path)
98+
jax_model, jax_config = load_model_jax(model_path, use_kernel)
9799
print("Converting JAX model to PyTorch ...")
98100
self.model, self.config = convert_model_jax_to_torch(
99101
jax_model, jax_config, use_kernel

nequix/model.py

Lines changed: 101 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import json
22
import math
3-
from typing import Callable, Optional, Sequence
3+
import os
4+
from typing import Any, Callable, Optional, Sequence
45

56
import e3nn_jax as e3nn
67
import equinox as eqx
@@ -10,6 +11,22 @@
1011

1112
from nequix.layer_norm import RMSLayerNorm
1213

14+
try:
15+
import torch # noqa: F401
16+
except ImportError:
17+
# allow openequivariance to be imported without torch, but only if it is not
18+
# installed; otherwise, the torch backend won't work if users want to use
19+
# both torch and jax.
20+
os.environ["OEQ_NOTORCH"] = "1"
21+
22+
try:
23+
import openequivariance as oeq
24+
import openequivariance_extjax # noqa: F401
25+
26+
OEQ_AVAILABLE = True
27+
except ImportError:
28+
OEQ_AVAILABLE = False
29+
1330

1431
def bessel_basis(x: jax.Array, num_basis: int, r_max: float) -> jax.Array:
1532
prefactor = 2.0 / r_max
@@ -32,6 +49,23 @@ def polynomial_cutoff(x: jax.Array, r_max: float, p: float) -> jax.Array:
3249
return out * jnp.where(x < 1.0, 1.0, 0.0)
3350

3451

52+
class Sort(eqx.Module):
53+
irreps: e3nn.Irreps = eqx.field(static=True)
54+
irreps_sorted: e3nn.Irreps = eqx.field(static=True)
55+
slices_sorted: list = eqx.field(static=True)
56+
57+
def __init__(self, irreps: e3nn.Irreps):
58+
self.irreps = irreps
59+
slices = list(irreps.slices())
60+
irreps_sorted, _, inv = irreps.sort()
61+
self.slices_sorted = [slices[i] for i in inv]
62+
self.irreps_sorted = irreps_sorted
63+
64+
def __call__(self, x: jax.Array) -> jax.Array:
65+
chunks = [x[..., s] for s in self.slices_sorted]
66+
return jnp.concatenate(chunks, axis=-1)
67+
68+
3569
class Linear(eqx.Module):
3670
weights: jax.Array
3771
bias: Optional[jax.Array]
@@ -96,14 +130,18 @@ def __call__(self, x: jax.Array) -> jax.Array:
96130

97131
class NequixConvolution(eqx.Module):
98132
output_irreps: e3nn.Irreps = eqx.field(static=True)
133+
tp_irreps: e3nn.Irreps = eqx.field(static=True)
99134
index_weights: bool = eqx.field(static=True)
100135
avg_n_neighbors: float = eqx.field(static=True)
136+
kernel: bool = eqx.field(static=True)
137+
tp_conv: Optional[Any] = eqx.field(static=True)
101138

102139
radial_mlp: MLP
103140
linear_1: e3nn.equinox.Linear
104141
linear_2: e3nn.equinox.Linear
105142
skip: e3nn.equinox.Linear
106143
layer_norm: Optional[RMSLayerNorm]
144+
sort: Sort
107145

108146
def __init__(
109147
self,
@@ -119,12 +157,50 @@ def __init__(
119157
avg_n_neighbors: float,
120158
index_weights: bool = True,
121159
layer_norm: bool = False,
160+
kernel: bool = False,
122161
):
123162
self.output_irreps = output_irreps
124163
self.avg_n_neighbors = avg_n_neighbors
125164
self.index_weights = index_weights
165+
self.kernel = kernel
166+
167+
irreps_out_tp = []
168+
instructions = []
169+
for i, (mul, ir_in1) in enumerate(input_irreps):
170+
for j, (_, ir_in2) in enumerate(sh_irreps):
171+
for ir_out in ir_in1 * ir_in2:
172+
if ir_out in output_irreps:
173+
k = len(irreps_out_tp)
174+
irreps_out_tp.append((mul, ir_out))
175+
instructions.append((i, j, k, "uvu", True))
176+
177+
tp_irreps = e3nn.Irreps(irreps_out_tp)
178+
_, _, inv = tp_irreps.sort()
179+
self.tp_irreps = tp_irreps
180+
181+
if kernel:
182+
instructions = [instructions[i] for i in inv]
183+
if not OEQ_AVAILABLE:
184+
raise ImportError(
185+
"OpenEquivariance with JAX support is required for kernel=True. "
186+
"Install both packages:\n"
187+
" uv pip install 'openequivariance[jax]'\n"
188+
" uv pip install 'openequivariance_extjax' --no-build-isolation"
189+
)
190+
problem = oeq.TPProblem(
191+
str(input_irreps),
192+
str(sh_irreps),
193+
str(tp_irreps),
194+
instructions=instructions,
195+
shared_weights=False,
196+
internal_weights=False,
197+
)
198+
self.tp_conv = oeq.jax.TensorProductConv(problem, deterministic=False)
199+
else:
200+
self.tp_conv = None
126201

127-
tp_irreps = e3nn.tensor_product(input_irreps, sh_irreps, filter_ir_out=output_irreps)
202+
self.sort = Sort(tp_irreps)
203+
tp_irreps = self.sort.irreps_sorted
128204

129205
k1, k2, k3, k4 = jax.random.split(key, 4)
130206

@@ -182,14 +258,27 @@ def __call__(
182258
senders: jax.Array,
183259
receivers: jax.Array,
184260
) -> e3nn.IrrepsArray:
185-
messages = self.linear_1(features)[senders]
186-
messages = e3nn.tensor_product(messages, sh, filter_ir_out=self.output_irreps)
261+
messages = self.linear_1(features)
187262
radial_message = jax.vmap(self.radial_mlp)(radial_basis)
188-
messages = messages * radial_message
189263

190-
messages_agg = e3nn.scatter_sum(
191-
messages, dst=receivers, output_size=features.shape[0]
192-
) / jnp.sqrt(jax.lax.stop_gradient(self.avg_n_neighbors))
264+
if self.kernel:
265+
messages_agg = self.sort(
266+
self.tp_conv.forward(
267+
messages.array,
268+
sh.array,
269+
radial_message,
270+
receivers.astype(jnp.int32),
271+
senders.astype(jnp.int32),
272+
)
273+
)
274+
messages_agg = e3nn.IrrepsArray(self.sort.irreps_sorted, messages_agg)
275+
else:
276+
messages = messages[senders]
277+
messages = e3nn.tensor_product(messages, sh, filter_ir_out=self.tp_irreps)
278+
messages = messages * radial_message
279+
messages_agg = e3nn.scatter_sum(messages, dst=receivers, output_size=features.shape[0])
280+
281+
messages_agg = messages_agg / jnp.sqrt(jax.lax.stop_gradient(self.avg_n_neighbors))
193282

194283
skip = self.skip(species, features) if self.index_weights else self.skip(features)
195284
features = self.linear_2(messages_agg) + skip
@@ -237,6 +326,7 @@ def __init__(
237326
avg_n_neighbors: float = 1.0,
238327
atom_energies: Optional[Sequence[float]] = None,
239328
layer_norm: bool = False,
329+
kernel: bool = False,
240330
):
241331
self.lmax = lmax
242332
self.cutoff = cutoff
@@ -271,6 +361,7 @@ def __init__(
271361
avg_n_neighbors=avg_n_neighbors,
272362
index_weights=index_weights,
273363
layer_norm=layer_norm,
364+
kernel=kernel,
274365
)
275366
)
276367

@@ -446,7 +537,7 @@ def save_model(path: str, model: eqx.Module, config: dict):
446537
eqx.tree_serialise_leaves(f, model)
447538

448539

449-
def load_model(path: str) -> tuple[Nequix, dict]:
540+
def load_model(path: str, kernel: bool = False) -> tuple[Nequix, dict]:
450541
"""Load a model and its config from a file."""
451542
with open(path, "rb") as f:
452543
config = json.loads(f.readline().decode())
@@ -467,6 +558,7 @@ def load_model(path: str) -> tuple[Nequix, dict]:
467558
shift=config["shift"],
468559
scale=config["scale"],
469560
avg_n_neighbors=config["avg_n_neighbors"],
561+
kernel=kernel,
470562
# NOTE: atom_energies will be in model weights
471563
)
472564
model = eqx.tree_deserialise_leaves(f, model)

nequix/pft/train.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ def train(config_path):
192192
with open(config_path, "r") as f:
193193
config = yaml.safe_load(f)
194194

195-
model, original_config = load_model(config["finetune_from"])
195+
model, original_config = load_model(config["finetune_from"], config["kernel"])
196196

197197
if config["optimizer"] == "muon":
198198
optim = optax.chain(

nequix/torch/utils.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ def convert_layer_torch_to_jax(layer_idx, torch_model, jax_model):
131131
return jax_model
132132

133133

134-
def convert_model_torch_to_jax(torch_model, config):
134+
def convert_model_torch_to_jax(torch_model, config, use_kernel):
135135
jax_model = Nequix(
136136
key=jax.random.key(0),
137137
n_species=len(config["atomic_numbers"]),
@@ -150,6 +150,7 @@ def convert_model_torch_to_jax(torch_model, config):
150150
scale=config["scale"],
151151
avg_n_neighbors=config["avg_n_neighbors"],
152152
atom_energies=[config["atom_energies"][str(n)] for n in config["atomic_numbers"]],
153+
kernel=use_kernel,
153154
)
154155
for layer_idx in range(len(torch_model.layers)):
155156
jax_model = convert_layer_torch_to_jax(layer_idx, torch_model, jax_model)

nequix/train.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,7 @@ def train(config_path: str):
262262
scale=stats["scale"],
263263
avg_n_neighbors=stats["avg_n_neighbors"],
264264
atom_energies=atom_energies,
265+
kernel=config["kernel"],
265266
)
266267
if "finetune_from" in config and Path(config["finetune_from"]).exists():
267268
if "atom_energies" in config:

pyproject.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,16 @@ jax-md = { git = "https://github.com/jax-md/jax-md.git" }
6363
[project.optional-dependencies]
6464
torch = [
6565
"e3nn>=0.5.8",
66-
"openequivariance==0.4.1",
66+
"openequivariance>=0.5.4",
6767
"torch==2.7.0",
6868
"torch-geometric>=2.6.1",
6969
"setuptools",
7070
]
71+
oeq = [
72+
# You need to install openequivariance_extjax separately
73+
# uv pip install openequivariance_extjax --no-build-isolation
74+
"openequivariance[jax]>=0.5.4",
75+
]
7176
pft = [
7277
"phonopy>=2.43.1",
7378
]

0 commit comments

Comments
 (0)