Skip to content

Commit 1a73833

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 varying manual axes 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. Verified on GPU: the trace-time failure is gone on a 4-way sharded PET training run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent b3dc553 commit 1a73833

2 files changed

Lines changed: 93 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).vma)
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/test_shard_map.py

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

0 commit comments

Comments
 (0)