Skip to content

Commit e6bb8b7

Browse files
committed
Add JVP-safe fixed-index scatter
1 parent 6498a55 commit e6bb8b7

6 files changed

Lines changed: 126 additions & 26 deletions

File tree

README.md

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -114,25 +114,31 @@ JVP is useful for more specialized atomistic workflows, including:
114114
- debugging equivariance by differentiating along an infinitesimal rotation.
115115

116116
MLX 0.31 cannot currently apply JVP directly to a `CustomKernel` primitive.
117-
For spherical harmonics and tensor products, select the fully differentiable
118-
MLX implementation:
117+
For these workflows, select the fully differentiable MLX implementation:
119118

120119
```python
121120
import e3nn_mlx
122121

123-
# Spherical harmonics
122+
# Spherical harmonics and fixed-topology graph reduction
124123
y = e3nn_mlx.spherical_harmonics(
125124
degrees, vectors, use_custom_kernel=False
126125
)
126+
summed = e3nn_mlx.scatter_sum(
127+
messages,
128+
edge_dst,
129+
num_nodes,
130+
use_custom_kernel=False,
131+
jvp_safe=True,
132+
)
127133

128134
# TensorProduct: use this callable inside mx.jvp
129135
y = tensor_product.differentiable_arrays(left, right, weights)
130136
```
131137

132-
MLX 0.31's indexed-add primitive does not implement JVP, so neither scatter
133-
execution path is a forward-mode fallback. `scatter_sum` supports ordinary
134-
reverse-mode gradients; compute a message JVP before aggregation or use a
135-
problem-specific fixed incidence matrix when a graph-reduction JVP is required.
138+
Because MLX 0.31's indexed-add primitive does not implement JVP, the scatter
139+
fallback requires eager, fixed indices. It uses a sparse sorted prefix sum with
140+
linear memory rather than a dense node-by-edge incidence matrix. Ordinary
141+
scatter calls keep the faster indexed-add implementation.
136142

137143
This changes execution strategy, not mathematical conventions or accuracy.
138144
Reverse-mode gradients and reverse-over-reverse second derivatives remain

docs/guide/performance.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,11 @@ harmonics = o3.spherical_harmonics(
4040
)
4141
```
4242

43-
Scatter is a separate boundary: MLX 0.31's indexed-add primitive does not
44-
implement JVP, so `scatter_sum` supports reverse-mode differentiation but has
45-
no forward-mode fallback. Compute a message JVP before aggregation or use a
46-
problem-specific fixed incidence matrix when the reduction itself needs JVP.
43+
For graph reduction, use
44+
`scatter_sum(..., use_custom_kernel=False, jvp_safe=True)`. MLX 0.31's
45+
indexed-add primitive does not implement JVP, so this fixed-index fallback uses
46+
a sparse sorted prefix sum with linear memory. Ordinary scatter calls retain
47+
the faster indexed-add implementation.
4748

4849
See the repository's
4950
[kernel evaluation guide](https://github.com/lamalab-org/e3nn_mlx/blob/main/evals/KERNEL_EVALUATION.md)

e3nn_mlx/graph.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,15 @@ def scatter_sum(
1111
dim_size: int | None = None,
1212
*,
1313
use_custom_kernel: bool = False,
14+
jvp_safe: bool = False,
1415
):
15-
"""Sum rows of ``source`` into output rows selected by one-dimensional ``index``."""
16+
"""Sum rows of ``source`` into rows selected by one-dimensional ``index``.
17+
18+
Set ``jvp_safe=True`` for forward-mode differentiation with respect to
19+
``source``. This path requires eager, fixed indices and implements the
20+
same reduction with a sorted prefix sum because MLX 0.31's indexed-add
21+
primitive has no JVP rule.
22+
"""
1623

1724
mx, _ = require_mlx()
1825
if source.ndim < 1:
@@ -25,6 +32,10 @@ def scatter_sum(
2532
dim_size = 0 if index.size == 0 else int(mx.max(index).item()) + 1
2633
if dim_size < 0:
2734
raise ValueError("dim_size must be non-negative")
35+
if jvp_safe:
36+
if use_custom_kernel:
37+
raise ValueError("jvp_safe=True requires use_custom_kernel=False")
38+
return _fixed_index_scatter_sum(source, index, dim_size)
2839
use_metal = (
2940
use_custom_kernel
3041
and mlx_metal_available()
@@ -51,6 +62,44 @@ def scatter_sum(
5162
return output.at[index].add(source)
5263

5364

65+
def _fixed_index_scatter_sum(source, index, dim_size: int):
66+
"""Sparse JVP-safe scatter for an eager, fixed index array."""
67+
68+
mx, _ = require_mlx()
69+
import numpy as np
70+
71+
try:
72+
host_index = np.asarray(index)
73+
except Exception as error:
74+
raise RuntimeError(
75+
"jvp_safe scatter requires an eager fixed index array"
76+
) from error
77+
if host_index.size:
78+
minimum = int(host_index.min())
79+
maximum = int(host_index.max())
80+
if minimum < 0 or maximum >= dim_size:
81+
raise ValueError("index values must satisfy 0 <= index < dim_size")
82+
83+
order_host = np.argsort(host_index, kind="stable")
84+
sorted_index = host_index[order_host]
85+
rows = np.arange(dim_size, dtype=host_index.dtype)
86+
starts_host = np.searchsorted(sorted_index, rows, side="left")
87+
ends_host = np.searchsorted(sorted_index, rows, side="right")
88+
order = mx.array(order_host, dtype=mx.int32)
89+
starts = mx.array(starts_host, dtype=mx.int32)
90+
ends = mx.array(ends_host, dtype=mx.int32)
91+
92+
sorted_source = source[order]
93+
prefix = mx.concatenate(
94+
(
95+
mx.zeros((1, *source.shape[1:]), dtype=source.dtype),
96+
mx.cumsum(sorted_source, axis=0),
97+
),
98+
axis=0,
99+
)
100+
return prefix[ends] - prefix[starts]
101+
102+
54103
def radius_graph(positions, radius: float, batch=None):
55104
"""Build the directed, loop-free batched radius graph used by gate-points models.
56105

evals/KERNEL_EVALUATION.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,10 @@ derivatives. MLX 0.31 cannot currently apply JVP directly to a `CustomKernel`
2727
primitive even when its containing custom function supplies a JVP rule. Code
2828
which needs forward-mode transformation should therefore call tensor-product
2929
`differentiable_arrays`, or pass `use_custom_kernel=False` to spherical
30-
harmonics. MLX's indexed-add primitive also lacks JVP, so scatter has no general
31-
forward-mode fallback in MLX 0.31. This is an explicit runtime boundary, not a
32-
numerical approximation.
30+
harmonics. For fixed-topology scatter, pass `use_custom_kernel=False` and
31+
`jvp_safe=True`; this avoids MLX's missing indexed-add JVP with a sparse sorted
32+
prefix sum. This is an explicit execution boundary, not a numerical
33+
approximation.
3334

3435
Typical JVP users are phonon and vibrational-response calculations,
3536
Hessian-vector products, mixed position/parameter response, tangent dynamics,

tests/test_documentation_contract.py

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -300,12 +300,12 @@
300300
),
301301
(
302302
"README.md",
303-
"MLX 0.31's indexed-add primitive does not implement JVP",
303+
"the scatter fallback requires eager, fixed indices",
304304
"tests/test_documentation_contract.py::test_documented_general_paths_support_jvp",
305305
),
306306
(
307307
"docs/guide/performance.md",
308-
"`scatter_sum` supports reverse-mode differentiation but has no forward-mode fallback",
308+
"`scatter_sum(..., use_custom_kernel=False, jvp_safe=True)`",
309309
"tests/test_documentation_contract.py::test_documented_general_paths_support_jvp",
310310
),
311311
(
@@ -626,14 +626,21 @@ def test_documented_general_paths_support_jvp() -> None:
626626

627627
source = mx.arange(8, dtype=mx.float32).reshape(4, 2)
628628
index = mx.array([0, 1, 0, 1], dtype=mx.int32)
629-
with pytest.raises(RuntimeError, match="JVP not yet implemented"):
630-
mx.jvp(
631-
lambda value: scatter_sum(
632-
value, index, 2, use_custom_kernel=False
633-
),
634-
(source,),
635-
(mx.ones_like(source),),
636-
)
629+
(scattered,), (scatter_tangent,) = mx.jvp(
630+
lambda value: scatter_sum(
631+
value,
632+
index,
633+
2,
634+
use_custom_kernel=False,
635+
jvp_safe=True,
636+
),
637+
(source,),
638+
(mx.ones_like(source),),
639+
)
640+
expected_scatter = scatter_sum(source, index, 2)
641+
expected_tangent = scatter_sum(mx.ones_like(source), index, 2)
642+
assert _maximum_error(scattered, expected_scatter) == 0.0
643+
assert _maximum_error(scatter_tangent, expected_tangent) == 0.0
637644

638645
product = o3.FullTensorProduct("1o", "1o", use_custom_kernel=True)
639646
right = mx.array([[-0.5, 0.1, 0.7], [0.2, 0.4, -0.1]], dtype=mx.float32)
@@ -644,8 +651,9 @@ def test_documented_general_paths_support_jvp() -> None:
644651
)
645652
assert product_value.shape == product_tangent.shape
646653
assert product_value.shape == (2, product.irreps_out.dim)
647-
mx.eval(harmonics_tangent, product_tangent)
654+
mx.eval(harmonics_tangent, scatter_tangent, product_tangent)
648655
assert bool(mx.all(mx.isfinite(harmonics_tangent)))
656+
assert bool(mx.all(mx.isfinite(scatter_tangent)))
649657
assert bool(mx.all(mx.isfinite(product_tangent)))
650658

651659

tests/test_graph_radial.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,26 @@ def test_scatter_sum_values_multidimensional_empty_compile_and_gradient() -> Non
119119
gradient = mx.grad(lambda x: mx.sum(e3nn.scatter_sum(x, index, 3) ** 2))(source)
120120
mx.eval(gradient)
121121
assert bool(mx.all(mx.isfinite(gradient)))
122+
safe = e3nn.scatter_sum(source, index, 3, jvp_safe=True)
123+
assert _max_abs(safe - expected) == 0.0
124+
safe_gradient = mx.grad(
125+
lambda x: mx.sum(e3nn.scatter_sum(x, index, 3, jvp_safe=True) ** 2)
126+
)(source)
127+
assert _max_abs(safe_gradient - gradient) == 0.0
128+
(jvp_output,), (jvp_tangent,) = mx.jvp(
129+
lambda x: e3nn.scatter_sum(x, index, 3, jvp_safe=True),
130+
(source,),
131+
(mx.ones_like(source),),
132+
)
133+
assert _max_abs(jvp_output - expected) == 0.0
134+
assert _max_abs(
135+
jvp_tangent
136+
- e3nn.scatter_sum(mx.ones_like(source), index, 3, jvp_safe=True)
137+
) == 0.0
138+
compiled_safe = mx.compile(
139+
lambda x: e3nn.scatter_sum(x, index, 3, jvp_safe=True)
140+
)
141+
assert _max_abs(compiled_safe(source) - expected) == 0.0
122142
empty = e3nn.scatter_sum(mx.zeros((0, 4)), mx.zeros((0,), dtype=mx.int32), 2)
123143
assert empty.shape == (2, 4)
124144

@@ -130,6 +150,21 @@ def test_scatter_sum_validation() -> None:
130150
e3nn.scatter_sum(mx.ones((2, 3)), mx.array([0], dtype=mx.int32), 1)
131151
with pytest.raises(TypeError, match="integer"):
132152
e3nn.scatter_sum(mx.ones((2, 3)), mx.array([0.0, 0.0]), 1)
153+
with pytest.raises(ValueError, match="requires use_custom_kernel=False"):
154+
e3nn.scatter_sum(
155+
mx.ones((2, 3)),
156+
mx.array([0, 0], dtype=mx.int32),
157+
1,
158+
use_custom_kernel=True,
159+
jvp_safe=True,
160+
)
161+
with pytest.raises(ValueError, match="0 <= index < dim_size"):
162+
e3nn.scatter_sum(
163+
mx.ones((2, 3)),
164+
mx.array([0, 2], dtype=mx.int32),
165+
2,
166+
jvp_safe=True,
167+
)
133168

134169

135170
@pytest.mark.mlx

0 commit comments

Comments
 (0)