Skip to content

Commit d6aa133

Browse files
E-Rumclaude
andcommitted
select: keep the solver's Newton carry varying under shard_map
The solver adaptive-cutoff path seeded its fori_loop bracket from constants (jnp.zeros / jnp.full), while the loop body's outputs derive from r_ij. Under shard_map r_ij varies over the data-parallel mesh axis, so carry-in and carry-out disagreed on manual axis types and scan's carry type check rejected the loop at trace time: TypeError: scan body function carry input and carry output must have equal types [...] float32[1024] vs float32[1024]{V:dp} Any data-parallel run using adaptive_cutoff_method="solver" failed to trace; single-device runs were unaffected, since callers only apply shard_map when there is more than one device. pcast the bracket to varying, reading the axes off the data so the fix is agnostic to the caller's mesh axis names and a no-op outside shard_map. Runtime behaviour is unchanged: each shard already solves on its own atoms, and the annotation carries no computation. Adds tests/test_shard_map.py, which runs both adaptive-cutoff methods under shard_map on forced CPU devices and checks values and gradients against the single-device result -- multi-device coverage the suite lacked. The device count is forced in conftest, before any test module imports jax. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent b3dc553 commit d6aa133

3 files changed

Lines changed: 95 additions & 3 deletions

File tree

src/petjax/select.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -298,9 +298,14 @@ def get_adaptive_cutoffs_solver(
298298
r_ij_d = jax.lax.stop_gradient(r_ij)
299299

300300
# Bracket [r_lo, r_hi] with f(r_lo) <= 0 <= f(r_hi): n_total(0) = 0 and the
301-
# baseline alone reaches num_neighbors at r = cutoff.
302-
r_lo = jnp.zeros(num_atoms, dtype=r_ij.dtype)
303-
r_hi = jnp.full(num_atoms, cutoff, dtype=r_ij.dtype)
301+
# baseline alone reaches num_neighbors at r = cutoff. The carry must enter
302+
# varying to match the body's outputs (derived from this shard's r_ij);
303+
# axes read off the data, so no-op outside shard_map.
304+
varying = tuple(jax.typeof(r_ij_d).manual_axis_type.varying)
305+
r_lo = jax.lax.pcast(jnp.zeros(num_atoms, dtype=r_ij.dtype), varying, to="varying")
306+
r_hi = jax.lax.pcast(
307+
jnp.full(num_atoms, cutoff, dtype=r_ij.dtype), varying, to="varying"
308+
)
304309

305310
# 10 iterations converge to fp32 precision (upstream's choice). Newton
306311
# steps that would leave the bracket (flat shoulders between bumps) fall

tests/conftest.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,16 @@
1212
individually.
1313
"""
1414

15+
import os
1516
from pathlib import Path
1617

1718
import pytest
1819

20+
# Enough CPU devices for the shard_map tests. XLA reads this at backend init,
21+
# so it must be set before any test module imports jax -- conftest is imported
22+
# first, which makes this the one place it can live.
23+
os.environ.setdefault("XLA_FLAGS", "--xla_force_host_platform_device_count=4")
24+
1925
ASSETS = Path(__file__).parent / "assets"
2026

2127

tests/test_shard_map.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""Adaptive cutoffs under shard_map (data-parallel training).
2+
3+
Callers such as iris wrap the step in `jax.shard_map` over a data-parallel
4+
mesh axis, which makes every array derived from the local batch "varying"
5+
over that axis. Code traced inside must keep loop carries varying too. The
6+
device count is forced in conftest, so these run on CPU -- no GPU needed.
7+
"""
8+
9+
import numpy as np
10+
import jax
11+
import jax.numpy as jnp
12+
from jax.sharding import NamedSharding
13+
from jax.sharding import PartitionSpec as P
14+
15+
import pytest
16+
17+
from petjax.select import get_adaptive_cutoffs, get_adaptive_cutoffs_solver
18+
19+
N_ATOMS = 64
20+
N_PAIRS = 512
21+
CUTOFF = 7.5
22+
CUTOFF_WIDTH = 1.0
23+
NUM_NEIGHBORS = 16
24+
25+
pytestmark = pytest.mark.skipif(
26+
jax.device_count() < 2, reason="needs >= 2 devices (XLA_FLAGS device count)"
27+
)
28+
29+
30+
def _inputs(n_dev):
31+
rng = np.random.default_rng(0)
32+
return (
33+
jnp.asarray(rng.integers(0, N_ATOMS, size=(n_dev, N_PAIRS)), dtype=jnp.int32),
34+
jnp.asarray(rng.uniform(0.5, CUTOFF, size=(n_dev, N_PAIRS)), dtype=jnp.float32),
35+
jnp.asarray(rng.random((n_dev, N_PAIRS)) > 0.1),
36+
)
37+
38+
39+
def _sharded(fn, mesh):
40+
"""Mirror the training wrapper: shard on the leading axis, squeeze, call."""
41+
42+
@jax.jit
43+
def wrapped(*args):
44+
@jax.shard_map(mesh=mesh, in_specs=(P("dp"),) * len(args), out_specs=P("dp"))
45+
def inner(*args):
46+
return fn(*[jnp.squeeze(a, 0) for a in args])[None]
47+
48+
return inner(*args)
49+
50+
return wrapped
51+
52+
53+
@pytest.mark.parametrize(
54+
"method",
55+
[get_adaptive_cutoffs_solver, get_adaptive_cutoffs],
56+
ids=["solver", "grid"],
57+
)
58+
def test_adaptive_cutoffs_match_single_device(method):
59+
n_dev = jax.device_count()
60+
centers, r_ij, pair_mask = _inputs(n_dev)
61+
62+
def solve(centers, r_ij, pair_mask):
63+
return method(
64+
centers, r_ij, pair_mask, NUM_NEIGHBORS, N_ATOMS, CUTOFF, CUTOFF_WIDTH
65+
)
66+
67+
ref = jnp.stack([solve(centers[i], r_ij[i], pair_mask[i]) for i in range(n_dev)])
68+
69+
mesh = jax.make_mesh((n_dev,), ("dp",))
70+
shard = NamedSharding(mesh, P("dp"))
71+
args = [jax.device_put(x, shard) for x in (centers, r_ij, pair_mask)]
72+
got = _sharded(solve, mesh)(*args)
73+
74+
# 1 ULP: XLA fuses the segment sums differently under shard_map
75+
np.testing.assert_allclose(np.asarray(got), np.asarray(ref), rtol=1e-6, atol=1e-6)
76+
77+
with jax.set_mesh(mesh): # the scalar output lives on the mesh
78+
grad = jax.jit(
79+
jax.grad(lambda r: _sharded(solve, mesh)(args[0], r, args[2]).sum())
80+
)(args[1])
81+
assert jnp.all(jnp.isfinite(grad))

0 commit comments

Comments
 (0)