Skip to content

Commit ef6669a

Browse files
authored
Merge pull request #1 from lamalab-org/e3nn-kernel
E3nn kernel
2 parents b5e1014 + a593c0e commit ef6669a

29 files changed

Lines changed: 1986 additions & 57 deletions

README.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,47 @@ covers spherical harmonics, tensor products, Linear, v2106 convolution,
8080
message passing, and an end-to-end attributed network in forward and training
8181
modes. Small smoke presets, larger scaling experiments, synchronized timing,
8282
JSON/CSV results, and dependency-free SVG/HTML plot generation are included.
83+
Generated Metal kernels and the reproducible kernel/general-MLX/Torch-CPU
84+
comparison are documented in
85+
[`evals/KERNEL_EVALUATION.md`](evals/KERNEL_EVALUATION.md).
86+
87+
### JVP and generated-kernel boundary
88+
89+
A Jacobian-vector product (JVP) propagates a chosen input perturbation through
90+
a function without constructing its complete Jacobian. Most users do not call
91+
JVP directly: ordinary inference and standard training—including MACE energy,
92+
force, and parameter-gradient training—use forward evaluation and reverse-mode
93+
gradients.
94+
95+
JVP is useful for more specialized atomistic workflows, including:
96+
97+
- Hessian-vector products and directional force-constant calculations;
98+
- phonon, vibrational-response, and stability algorithms that propagate a
99+
displacement direction;
100+
- mixed position/parameter response calculations;
101+
- tangent dynamics, sensitivity analysis, and forward-mode Jacobian APIs;
102+
- debugging equivariance by differentiating along an infinitesimal rotation.
103+
104+
MLX 0.31 cannot currently apply JVP directly to a `CustomKernel` primitive.
105+
For these workflows, select the fully differentiable MLX implementation:
106+
107+
```python
108+
import e3nn_mlx
109+
110+
# Spherical harmonics and graph reduction
111+
y = e3nn_mlx.spherical_harmonics(
112+
degrees, vectors, use_custom_kernel=False
113+
)
114+
summed = e3nn_mlx.scatter_sum(
115+
messages, edge_dst, num_nodes, use_custom_kernel=False
116+
)
117+
118+
# TensorProduct: use this callable inside mx.jvp
119+
y = tensor_product.differentiable_arrays(left, right, weights)
120+
```
121+
122+
This changes execution strategy, not mathematical conventions or accuracy.
123+
Reverse-mode gradients and reverse-over-reverse second derivatives remain
124+
supported by the generated kernels. See
125+
[`evals/KERNEL_EVALUATION.md`](evals/KERNEL_EVALUATION.md) for the performance
126+
and fallback details.

e3nn_mlx/_metal_scatter.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"""Fused Metal scatter-add and gather-transpose operations."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
7+
from .compat import require_mlx
8+
9+
10+
_KERNEL_CACHE: dict[int, tuple[Any, Any]] = {}
11+
12+
13+
def _kernels(width: int):
14+
mx, _ = require_mlx()
15+
cached = _KERNEL_CACHE.get(width)
16+
if cached is not None:
17+
return cached
18+
forward = mx.fast.metal_kernel(
19+
name=f"e3nn_scatter_sum_{width}",
20+
input_names=["source", "index"],
21+
output_names=["output"],
22+
source=f"""
23+
uint tid = thread_position_in_grid.x;
24+
uint row = tid / {width};
25+
uint column = tid % {width};
26+
uint destination = uint(index[row]) * {width} + column;
27+
atomic_fetch_add_explicit(&output[destination], float(source[tid]), memory_order_relaxed);
28+
""",
29+
atomic_outputs=True,
30+
)
31+
transpose = mx.fast.metal_kernel(
32+
name=f"e3nn_scatter_transpose_{width}",
33+
input_names=["cotangent", "index"],
34+
output_names=["gradient"],
35+
source=f"""
36+
uint tid = thread_position_in_grid.x;
37+
uint row = tid / {width};
38+
uint column = tid % {width};
39+
gradient[tid] = T(cotangent[uint(index[row]) * {width} + column]);
40+
""",
41+
)
42+
_KERNEL_CACHE[width] = forward, transpose
43+
return forward, transpose
44+
45+
46+
def make_operation(index, dim_size: int, source_shape):
47+
"""Create a custom-differentiable scatter for a fixed index array."""
48+
49+
mx, _ = require_mlx()
50+
width = 1
51+
for dimension in source_shape[1:]:
52+
width *= dimension
53+
output_shape = (dim_size, *source_shape[1:])
54+
element_count = source_shape[0] * width
55+
forward_kernel, transpose_kernel = _kernels(width)
56+
57+
@mx.custom_function
58+
def differentiable_transpose(cotangent):
59+
return transpose_kernel(
60+
inputs=[cotangent, index],
61+
template=[("T", cotangent.dtype)],
62+
output_shapes=[source_shape],
63+
output_dtypes=[cotangent.dtype],
64+
grid=(element_count, 1, 1),
65+
threadgroup=(256, 1, 1),
66+
)[0]
67+
68+
@differentiable_transpose.vjp
69+
def transpose_vjp(cotangent, source_cotangent, _output):
70+
del cotangent
71+
return forward_kernel(
72+
inputs=[source_cotangent, index],
73+
template=[("T", source_cotangent.dtype)],
74+
output_shapes=[output_shape],
75+
output_dtypes=[source_cotangent.dtype],
76+
grid=(element_count, 1, 1),
77+
threadgroup=(256, 1, 1),
78+
init_value=0,
79+
)[0]
80+
81+
@mx.custom_function
82+
def operation(source):
83+
return forward_kernel(
84+
inputs=[source, index],
85+
template=[("T", source.dtype)],
86+
output_shapes=[output_shape],
87+
output_dtypes=[source.dtype],
88+
grid=(element_count, 1, 1),
89+
threadgroup=(256, 1, 1),
90+
init_value=0,
91+
)[0]
92+
93+
@operation.vjp
94+
def operation_vjp(source, cotangent, _output):
95+
del source
96+
return differentiable_transpose(cotangent)
97+
98+
@operation.jvp
99+
def operation_jvp(source, tangent):
100+
del source
101+
output = mx.zeros(output_shape, dtype=tangent.dtype)
102+
return output.at[index].add(tangent)
103+
104+
return operation

e3nn_mlx/_metal_sh.py

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
"""Generated Metal kernels for batches of real spherical harmonics."""
2+
3+
from __future__ import annotations
4+
5+
from collections.abc import Callable, Sequence
6+
from math import sqrt
7+
from typing import Any
8+
9+
from e3nn_core.cg import wigner_3j
10+
11+
from .compat import require_mlx
12+
13+
14+
_KERNEL_CACHE: dict[tuple[tuple[int, ...], bool, tuple[float, ...]], tuple[Any, Any]] = {}
15+
16+
17+
def _literal(value: float) -> str:
18+
literal = f"{value:.17g}"
19+
if "." not in literal and "e" not in literal:
20+
literal += ".0"
21+
return literal + "f"
22+
23+
24+
def _program(
25+
degrees: tuple[int, ...], normalize: bool, scales: tuple[float, ...], *, backward: bool
26+
) -> str:
27+
lmax = max(degrees)
28+
lines = [
29+
"uint item = thread_position_in_grid.x;",
30+
"uint input_base = item * 3;",
31+
"float x0 = float(vectors[input_base]);",
32+
"float x1 = float(vectors[input_base + 1]);",
33+
"float x2 = float(vectors[input_base + 2]);",
34+
]
35+
if normalize:
36+
lines += [
37+
"float radius = sqrt(x0*x0 + x1*x1 + x2*x2);",
38+
"float inverse_radius = radius > 0.0f ? 1.0f / max(radius, 1.0e-12f) : 0.0f;",
39+
"float n0 = x0 * inverse_radius;",
40+
"float n1 = x1 * inverse_radius;",
41+
"float n2 = x2 * inverse_radius;",
42+
]
43+
else:
44+
lines += ["float n0 = x0;", "float n1 = x1;", "float n2 = x2;"]
45+
lines.append("float y0_0 = 1.0f;")
46+
if backward:
47+
lines += [f"float dy0_0_{k} = 0.0f;" for k in range(3)]
48+
if lmax:
49+
root3 = sqrt(3.0)
50+
for component in range(3):
51+
lines.append(f"float y1_{component} = {_literal(root3)} * n{component};")
52+
if backward:
53+
for k in range(3):
54+
if normalize:
55+
derivative = (
56+
f"inverse_radius * ({'1.0f' if component == k else '0.0f'} "
57+
f"- n{component} * n{k})"
58+
)
59+
else:
60+
derivative = "1.0f" if component == k else "0.0f"
61+
lines.append(
62+
f"float dy1_{component}_{k} = {_literal(root3)} * ({derivative});"
63+
)
64+
65+
for l in range(1, lmax):
66+
coefficients = wigner_3j(l, 1, l + 1)
67+
factor = (2 * l + 3) / sqrt(3.0 * (l + 1))
68+
for c in range(2 * (l + 1) + 1):
69+
terms: list[tuple[int, int, float]] = []
70+
for a in range(2 * l + 1):
71+
for b in range(3):
72+
coefficient = float(coefficients[a][b][c]) * factor
73+
if coefficient != 0.0:
74+
terms.append((a, b, coefficient))
75+
expression = " + ".join(
76+
f"{_literal(coefficient)} * y{l}_{a} * y1_{b}"
77+
for a, b, coefficient in terms
78+
) or "0.0f"
79+
lines.append(f"float y{l + 1}_{c} = {expression};")
80+
if backward:
81+
for k in range(3):
82+
derivative = " + ".join(
83+
f"{_literal(coefficient)} * (dy{l}_{a}_{k} * y1_{b} + y{l}_{a} * dy1_{b}_{k})"
84+
for a, b, coefficient in terms
85+
) or "0.0f"
86+
lines.append(f"float dy{l + 1}_{c}_{k} = {derivative};")
87+
88+
output_offset = 0
89+
if backward:
90+
for k in range(3):
91+
lines.append(f"float gradient{k} = 0.0f;")
92+
for degree, scale in zip(degrees, scales, strict=True):
93+
for component in range(2 * degree + 1):
94+
for k in range(3):
95+
lines.append(
96+
f"gradient{k} += float(cotangent[item * {sum(2*d+1 for d in degrees)} + {output_offset}]) "
97+
f"* {_literal(scale)} * dy{degree}_{component}_{k};"
98+
)
99+
output_offset += 1
100+
lines += [f"vector_gradient[input_base + {k}] = T(gradient{k});" for k in range(3)]
101+
else:
102+
output_dim = sum(2 * degree + 1 for degree in degrees)
103+
for degree, scale in zip(degrees, scales, strict=True):
104+
for component in range(2 * degree + 1):
105+
lines.append(
106+
f"output[item * {output_dim} + {output_offset}] = "
107+
f"T({_literal(scale)} * y{degree}_{component});"
108+
)
109+
output_offset += 1
110+
return "\n".join(lines)
111+
112+
113+
def _kernels(degrees: tuple[int, ...], normalize: bool, scales: tuple[float, ...]):
114+
mx, _ = require_mlx()
115+
key = (degrees, normalize, scales)
116+
if key in _KERNEL_CACHE:
117+
return _KERNEL_CACHE[key]
118+
suffix = "_".join(map(str, degrees))
119+
forward = mx.fast.metal_kernel(
120+
name=f"e3nn_sh_forward_{suffix}_{int(normalize)}",
121+
input_names=["vectors"],
122+
output_names=["output"],
123+
source=_program(degrees, normalize, scales, backward=False),
124+
)
125+
backward = mx.fast.metal_kernel(
126+
name=f"e3nn_sh_backward_{suffix}_{int(normalize)}",
127+
input_names=["vectors", "cotangent"],
128+
output_names=["vector_gradient"],
129+
source=_program(degrees, normalize, scales, backward=True),
130+
)
131+
_KERNEL_CACHE[key] = forward, backward
132+
return forward, backward
133+
134+
135+
def make_operation(
136+
degrees: Sequence[int],
137+
normalize: bool,
138+
scales: Sequence[float],
139+
general: Callable[[Any], Any],
140+
):
141+
"""Create a fused per-vector spherical-harmonics operation."""
142+
143+
mx, _ = require_mlx()
144+
degrees = tuple(degrees)
145+
scales = tuple(scales)
146+
output_dim = sum(2 * degree + 1 for degree in degrees)
147+
forward_kernel, backward_kernel = _kernels(degrees, normalize, scales)
148+
149+
@mx.custom_function
150+
def differentiable_backward(vectors, cotangent):
151+
return backward_kernel(
152+
inputs=[vectors, cotangent],
153+
template=[("T", vectors.dtype)],
154+
output_shapes=[vectors.shape],
155+
output_dtypes=[vectors.dtype],
156+
grid=(vectors.shape[0], 1, 1),
157+
threadgroup=(256, 1, 1),
158+
)[0]
159+
160+
@differentiable_backward.vjp
161+
def differentiable_backward_vjp(primals, cotangent, _output):
162+
vectors, output_cotangent = primals
163+
164+
def general_backward(x, dy):
165+
_, (gradient,) = mx.vjp(general, (x,), (dy,))
166+
return gradient
167+
168+
_, gradients = mx.vjp(general_backward, (vectors, output_cotangent), (cotangent,))
169+
return gradients
170+
171+
@mx.custom_function
172+
def operation(vectors):
173+
return forward_kernel(
174+
inputs=[vectors],
175+
template=[("T", vectors.dtype)],
176+
output_shapes=[(vectors.shape[0], output_dim)],
177+
output_dtypes=[vectors.dtype],
178+
grid=(vectors.shape[0], 1, 1),
179+
threadgroup=(256, 1, 1),
180+
)[0]
181+
182+
@operation.vjp
183+
def operation_vjp(primals, cotangent, _output):
184+
return differentiable_backward(primals, cotangent)
185+
186+
@operation.jvp
187+
def operation_jvp(primals, tangents):
188+
_, tangent = mx.jvp(general, (primals,), (tangents,))
189+
return tangent
190+
191+
return operation

0 commit comments

Comments
 (0)