"""Shared Pallas-backend utilities used across the FD and FV paths.
This module is the single place that:
- imports Pallas / Triton (and exposes ``pl is None`` if unavailable),
- normalises ``config.pallas_block_shape`` to a 3-tuple,
- builds Triton ``CompilerParams`` from config knobs,
- exposes the ``backend == PALLAS`` predicate,
- provides the ``_pallas_call_sharded`` multi-GPU wrapper that turns an
opaque ``pl.pallas_call`` into a ``shard_map`` + ppermute halo-exchange
body when the user runs on a multi-device mesh.
Every Pallas kernel module under ``astronomix`` should import from here so
new knobs / fallbacks only need to be added once.
"""
# general
import contextvars
from contextlib import contextmanager
# jax
import jax
import jax.numpy as jnp
from jax.sharding import NamedSharding, PartitionSpec
# astronomix containers
from astronomix.option_classes.simulation_config import PALLAS, SimulationConfig
# Pallas / Triton are optional: a CPU-only or older JAX install may lack one or
# both. We import them defensively so the rest of the module loads (callers gate
# on ``pl is None`` / ``pltriton is None``) rather than failing at import time.
try:
from jax.experimental import pallas as pl
except Exception: # pragma: no cover - Pallas optional
pl = None
try:
from jax.experimental.pallas import triton as pltriton
except Exception: # pragma: no cover - Triton GPU backend optional
pltriton = None
def _backend_is_pallas(config: SimulationConfig) -> bool:
"""Return whether the configured backend is the Pallas/Triton GPU backend."""
return config.backend == PALLAS
def _default_pallas_block_shape(ndim: int) -> tuple[int, int, int]:
"""Return the default Pallas block shape ``(bx, by, bz)`` for ``ndim`` spatial
dimensions (inactive dimensions forced to 1)."""
if ndim == 1:
return (128, 1, 1)
if ndim == 2:
return (16, 16, 1)
return (4, 4, 8)
def _as_3tuple_block_shape(block_shape, ndim: int) -> tuple[int, int, int]:
"""Normalise whatever the user supplied (None / str / tuple) to
``(bx, by, bz)`` with the inactive dims forced to 1. Pallas grid
construction depends on this tuple being canonical."""
if block_shape is None:
return _default_pallas_block_shape(ndim)
if isinstance(block_shape, str):
parts = tuple(int(p.strip()) for p in block_shape.split(",") if p.strip())
else:
parts = tuple(int(x) for x in block_shape)
if len(parts) == 1:
parts = (parts[0], 1, 1)
elif len(parts) == 2:
parts = (parts[0], parts[1], 1)
elif len(parts) >= 3:
parts = parts[:3]
else:
parts = _default_pallas_block_shape(ndim)
if ndim == 1:
return (parts[0], 1, 1)
if ndim == 2:
return (parts[0], parts[1], 1)
return parts
def _pallas_compiler_params(config: SimulationConfig):
"""Return Triton ``CompilerParams`` (or None if the Triton backend is
not available / the user opted out via ``pallas_use_triton=False``)."""
use_triton = config.pallas_use_triton
if use_triton and pltriton is not None:
return pltriton.CompilerParams(
num_warps=config.pallas_num_warps,
)
return None
# -----------------------------------------------------------------------------
# Multi-GPU shard_map + halo wrapper.
#
# Every Pallas kernel in this codebase passes its state-shape input(s) to
# ``pl.pallas_call`` via ``BlockSpec(state.shape, lambda ...: (0, 0, 0, 0))``.
# That tells Pallas/Triton "each block program can read anywhere in the
# array", which is the correct (and fast) shape on a single device — but
# it makes the call entirely opaque to GSPMD. When the input is sharded
# across a device mesh, XLA's only legal lowering is to ``all-gather`` the
# whole state on every device before each ``pallas_call``, which dominates
# every kernel hot-loop and kills strong scaling (~0.95× on the FD Pallas
# sound-wave benchmark before this fix).
#
# The fix is mechanical: wrap each ``pl.pallas_call`` in a ``shard_map``
# body that
# 1. ppermutes a halo of ``stencil_reach`` cells from each neighbour
# shard along every sharded spatial axis (periodic ring),
# 2. concatenates [left_halo, local, right_halo] on each sharded axis,
# 3. calls the existing kernel on the local-padded shard (its modular
# indexing wraps within the padded shape; halo cells provide the
# correct neighbour values for interior reads),
# 4. strips the halo from the output.
#
# So the user-facing knob is just: ``pallas_mesh_context(mesh)`` around
# the JIT trace, plus each kernel calling ``_pallas_call_sharded`` instead
# of ``pl.pallas_call(...)(args)`` directly. No kernel arithmetic changes.
# -----------------------------------------------------------------------------
_pallas_mesh_ctx: contextvars.ContextVar = contextvars.ContextVar(
"astronomix_pallas_mesh", default=None
)
[docs]
@contextmanager
def pallas_mesh_context(mesh):
"""Set the active mesh for Pallas kernel sharding.
``time_integration`` enters this context around the JIT trace whenever
the user supplies a ``sharding`` argument. Inside the context every
Pallas kernel that calls ``_pallas_call_sharded`` will route through
a ``shard_map`` + ppermute halo exchange instead of the bare
``pl.pallas_call``.
When ``mesh`` is ``None`` (single-device run) or has size 1, the
helper is a no-op — the kernel runs exactly as before.
"""
token = _pallas_mesh_ctx.set(mesh)
try:
yield
finally:
_pallas_mesh_ctx.reset(token)
def _current_pallas_mesh():
return _pallas_mesh_ctx.get()
def _round_halo_up_to_block(halo, block_shape) -> tuple[int, ...]:
"""Round each natural halo width up to a multiple of the corresponding
Pallas block size. The kernel's internal ``grid = (nx // bx, ...)``
must remain block-divisible after halo padding, so we always grow the
halo to the nearest block multiple."""
out = []
for h, b in zip(halo, block_shape, strict=False):
h_i = int(h)
b_i = max(int(b), 1)
if h_i <= 0:
out.append(0)
else:
q, r = divmod(h_i, b_i)
out.append(b_i * (q + (1 if r else 0)))
return tuple(out)
def _spatial_sharded_axes(mesh, pspec, ndim):
"""Return a list of ``(array_axis_idx, mesh_axis_name, num_dev)`` for
every spatial array axis that is split across more than one device.
The variable axis (index 0) is always skipped."""
out = []
for ax in range(1, ndim + 1):
if ax >= len(pspec):
break
name = pspec[ax]
if name is None:
continue
if isinstance(name, tuple):
for nm in name:
n = mesh.shape[nm]
if n > 1:
out.append((ax, nm, n))
else:
n = mesh.shape[name]
if n > 1:
out.append((ax, name, n))
return out
def _default_state_pspec(mesh, ndim) -> PartitionSpec:
"""Best-effort PartitionSpec when an input array's ``.sharding`` is an
``UnspecifiedValue`` (which can happen for intermediates inside a
JIT trace). Assumes the standard ``(VARAXIS, XAXIS, YAXIS, ZAXIS)``
mesh emitted by ``pytests/_benchmark_utils.py::_build_sharding`` and
by callers that mirror it."""
axis_names = tuple(mesh.axis_names)
return PartitionSpec(*axis_names[: 1 + ndim])
def _pallas_call_sharded(
kernel_build_fn,
state_inputs,
other_args=(),
*,
halo,
block_shape,
num_state_outputs: int = 1,
):
"""Optionally wrap a Pallas-kernel build-and-call in ``shard_map``.
Args:
kernel_build_fn:
Callable ``(state_inputs_local_padded..., other_args...) -> out``
whose body builds and calls ``pl.pallas_call``. When the call
runs inside a ``shard_map`` body, each invocation sees the
*local* (halo-padded) shape and the kernel's internal
``grid``/``BlockSpec`` are built for that shape automatically.
state_inputs:
Tuple of state-shape arrays that all share the same sharding
(same ``PartitionSpec``). Each one is padded with halo cells
from neighbour shards along every sharded spatial axis.
other_args:
Tuple of replicated arrays (scalar dt, scalar gamma, ...)
passed through as ``PartitionSpec()``.
halo:
Per-spatial-axis natural stencil reach ``(hx, hy, hz)``.
Pointwise kernels pass ``(0, 0, 0)`` — they still get the
``shard_map`` (so the kernel runs locally on each shard),
just with no ppermute.
block_shape:
Per-spatial-axis Pallas block size ``(bx, by, bz)``. The
halo is rounded up to the nearest block multiple so the
padded shard remains block-divisible.
num_state_outputs:
Number of state-shape outputs of ``kernel_build_fn`` (1 for
most kernels; >1 for the CT staged kernels which return
tuples of single-channel arrays).
Returns:
Either ``kernel_build_fn(*state_inputs, *other_args)`` directly
(single-device path) or the equivalent ``shard_map``-wrapped
result with the halo stripped from each state-shape output.
"""
mesh = _current_pallas_mesh()
if mesh is None or mesh.size <= 1:
return kernel_build_fn(*state_inputs, *other_args)
state0 = state_inputs[0]
ndim = state0.ndim - 1
sharding = getattr(state0, "sharding", None)
if isinstance(sharding, NamedSharding):
pspec = sharding.spec
else:
pspec = _default_state_pspec(mesh, ndim)
sharded_axes = _spatial_sharded_axes(mesh, pspec, ndim)
if not sharded_axes:
return kernel_build_fn(*state_inputs, *other_args)
block_3 = tuple(block_shape) + (1,) * max(0, 3 - len(block_shape))
halo_3 = tuple(halo) + (0,) * max(0, 3 - len(halo))
halo_padded = _round_halo_up_to_block(halo_3[:ndim], block_3[:ndim])
try:
from jax.shard_map import shard_map # jax >= 0.8 (promoted out of experimental)
except ImportError: # jax < 0.8
from jax.experimental.shard_map import shard_map
def body(*all_args):
state_arrays = list(all_args[: len(state_inputs)])
others = all_args[len(state_inputs):]
for array_axis_idx, mesh_axis_name, num_dev in sharded_axes:
spatial_idx = array_axis_idx - 1
if spatial_idx >= len(halo_padded):
continue
h = halo_padded[spatial_idx]
if h <= 0:
continue
left_perm = [(j, (j - 1) % num_dev) for j in range(num_dev)]
right_perm = [(j, (j + 1) % num_dev) for j in range(num_dev)]
for i, arr in enumerate(state_arrays):
size = arr.shape[array_axis_idx]
left_edge = jax.lax.slice_in_dim(arr, 0, h, axis=array_axis_idx)
right_edge = jax.lax.slice_in_dim(
arr, size - h, size, axis=array_axis_idx
)
# Each device sends its right edge to the right neighbour;
# the receiving device installs the inbound payload as its
# *left* halo. Symmetric pattern for the right halo.
left_halo = jax.lax.ppermute(
right_edge, mesh_axis_name, perm=right_perm
)
right_halo = jax.lax.ppermute(
left_edge, mesh_axis_name, perm=left_perm
)
state_arrays[i] = jnp.concatenate(
[left_halo, arr, right_halo], axis=array_axis_idx
)
# Re-enter the wrapper with mesh=None so the recursive
# ``kernel_build_fn`` call goes through the no-wrap path. Without
# this, a kernel that calls ``_pallas_call_sharded`` from its body
# would wrap itself forever.
with pallas_mesh_context(None):
out = kernel_build_fn(*state_arrays, *others)
def _strip(o):
for array_axis_idx, _, _ in sharded_axes:
spatial_idx = array_axis_idx - 1
if spatial_idx >= len(halo_padded):
continue
h = halo_padded[spatial_idx]
if h <= 0:
continue
size = o.shape[array_axis_idx]
o = jax.lax.slice_in_dim(o, h, size - h, axis=array_axis_idx)
return o
if isinstance(out, tuple):
return tuple(_strip(o) for o in out)
return _strip(out)
state_specs = tuple(pspec for _ in state_inputs)
other_specs = tuple(PartitionSpec() for _ in other_args)
if num_state_outputs > 1:
out_specs = tuple(pspec for _ in range(num_state_outputs))
else:
out_specs = pspec
wrapped = shard_map(
body,
mesh=mesh,
in_specs=state_specs + other_specs,
out_specs=out_specs,
check_rep=False,
)
return wrapped(*state_inputs, *other_args)
# -----------------------------------------------------------------------------
# Differentiability: pair every Pallas entry with a native-JAX backward.
# -----------------------------------------------------------------------------
#
# Pallas kernels in this codebase use ``input_output_aliases`` for memory
# efficiency. JAX cannot transpose an aliased ``pl.pallas_call`` (``JVP with
# aliasing not supported``), so any path that hits a Pallas kernel is
# non-differentiable by default. We bridge that gap with a ``jax.custom_jvp``
# whose primal still calls the (aliased, fast) Pallas branch and whose
# tangent rule delegates to the equivalent native-JAX branch — which is
# already JVP-differentiable. Reverse-mode (``jax.grad``) is then derived by
# JAX via transposition.
#
# Forward simulation perf is unaffected: outside of AD the custom_jvp
# rule isn't invoked and the call collapses to the bare Pallas branch.
#
# Both branches must produce the same pytree-structured output. The Pallas
# guide promises bit-identical primal outputs for the existing kernels, so
# the gradient computed by transposing the native JVP at the Pallas-evaluated
# inputs is the correct gradient of the Pallas operation.
#
# Hand-rolled Pallas adjoint kernels can later replace the native tangent
# branch on a per-kernel basis without changing call sites.
[docs]
def diffable_pallas_call(state, params, *, pallas_branch, native_branch):
"""Run ``pallas_branch(state, params)`` with a custom_jvp boundary that
routes tangent computation through ``native_branch``.
Both branches must accept the same positional ``(state, params)`` pair
and produce the same pytree structure. Anything static (config,
registered_variables, axis index, ...) should be closed over.
Outside of AD the call collapses to ``pallas_branch(state, params)``
directly — no overhead. Under ``jax.jvp`` / ``jax.jacfwd`` /
``jax.grad`` / ``jax.vjp`` / ``jax.jacrev`` the custom rule fires and
the tangent goes through ``native_branch``.
"""
@jax.custom_jvp
def _f(s, p):
return pallas_branch(s, p)
@_f.defjvp
def _f_jvp(primals, tangents):
primal_out = pallas_branch(*primals)
_, tangent_out = jax.jvp(native_branch, primals, tangents)
return primal_out, tangent_out
return _f(state, params)
[docs]
def diffable_pallas_call_n(primals, *, pallas_branch, native_branch):
"""Same as :func:`diffable_pallas_call` but takes a tuple of arbitrary
differentiable primals (so callers with more than two diff args, e.g.
extra rhs/accumulator buffers, can still get a custom_jvp boundary)."""
@jax.custom_jvp
def _f(*args):
return pallas_branch(*args)
@_f.defjvp
def _f_jvp(args, tangents):
primal_out = pallas_branch(*args)
_, tangent_out = jax.jvp(native_branch, args, tangents)
return primal_out, tangent_out
return _f(*primals)
[docs]
def pallas_vjp_call(state, aux, *, pallas_forward, pallas_backward):
"""Run ``pallas_forward(state, aux)`` with a ``jax.custom_vjp`` boundary
whose reverse rule is a *native Pallas adjoint kernel* ``pallas_backward``.
Unlike :func:`diffable_pallas_call` (which routes the tangent — and hence
the transposed gradient — through native JAX), this keeps the entire
backward pass on the Pallas/GPU backend: ``pallas_backward(state, aux, cot)``
returns the input cotangent ``d(loss)/d(state)`` directly from a
hand-built adjoint kernel.
Differentiates w.r.t. ``state`` only. ``aux`` (e.g. the traced
``SimulationParams``) is threaded *through* the boundary and given a zero
cotangent — it must be passed explicitly rather than closed over because
``jax.custom_vjp`` cannot capture traced values in its forward/backward
closures (only static/concrete data — config, axis — may be closed over by
the two branches). Treating the physical constants as non-differentiable
matches the inverse-problem regime (gradients w.r.t. the state, not params).
NOTE: ``jax.custom_vjp`` supports reverse-mode only — ``jax.jvp`` /
forward-mode AD on this boundary raises. Use it for reverse-mode
(``jax.grad`` / ``differentiation_mode = BACKWARDS``); for forward-mode keep
:func:`diffable_pallas_call`.
"""
@jax.custom_vjp
def _f(s, a):
return pallas_forward(s, a)
def _f_fwd(s, a):
return pallas_forward(s, a), (s, a)
def _f_bwd(residual, cotangent):
s, a = residual
state_bar = pallas_backward(s, a, cotangent)
def _zero(x): # correctly-typed zero cotangent (float0 for non-inexact)
x = jnp.asarray(x)
if jnp.issubdtype(x.dtype, jnp.inexact):
return jnp.zeros_like(x)
return jnp.zeros(x.shape, dtype=jax.dtypes.float0)
return (state_bar, jax.tree_util.tree_map(_zero, a))
_f.defvjp(_f_fwd, _f_bwd)
return _f(state, aux)