Sharded static data and domain integrals (v0.2.1)#
Added in version v0.2.1: Per-device materialisation of StaticArray(replication="shard")
under ShardedStencilNode,
and the domain_integral_fields()
API for cross-device reduction of partial-sum outputs.
This page explains how to write a stencil node whose non-evolving arrays are sharded alongside state — needed when the array is big enough that replicating it per device would defeat the point of sharding — and how to declare outputs that are domain integrals over the lattice so the wrapper all-reduces them.
The motivating consumer is MIME’s IBLBMFluidNode: a D3Q19 LBM
node carrying a (nx, ny, nz) pipe-wall mask and a
(19, nx, ny, nz) “missing-link” bounce-back mask, both far too
large to replicate on every device when the simulation is sharded
across a pencil mesh.
The picture#
flowchart LR
subgraph "ShardedStencilNode.update()"
A[state pytree<br/>and StaticArray] --> B[device_put<br/>NamedSharding per shard_axis]
B --> C[shard_map]
C --> D[halo_exchange<br/>state slab + static slab]
D --> E[inner.update_padded<br/>static_padded=...<br/>shard_info=...]
E --> F[strip halos<br/>or psum integrals]
F --> G[next state]
end
Three things happen per step:
The wrapper takes each
StaticArray(replication="shard")on the inner node anddevice_puts it with aNamedShardingwhosePartitionSpecputs the matching mesh axis on the array’sshard_axis. This happens once, not per step: the placement is cached and re-checked against the inner node’sstatic_dataon every call, so a node that hands back a different array object (a mesh rebuilt after a parameter write, astatic_data_providerreconstruction, areplace_node) is picked up on the nextupdate. What the check cannot see is a static rewritten in place.GraphManager.compile()drops the cached placement on every node (and on every node they wrap), so the usual recovery is a recompile; outside a graph, callwrapper.invalidate_static_cache()after such a rewrite. Note that a rewrite with no recompile is not picked up by a laterstep()on its own: the graph’s static-data check hashes shape and dtype, never contents.Inside
shard_map, each device’s slab is halo-exchanged along the matching spatial axis. Between two shards the halo holds the neighbour’s cells. At the edges of the global grid it follows the wrapper’sboundarywhen that is"periodic": the cells beyond a periodic edge are the opposite edge’s cells, for a static array as much as for the state. Under"edge"and"zero"there is no cell beyond the edge, and the static’s halo repeats the edge cell. (The halo fill is a question about space, not time; until 0.4.0 statics were edge-filled under every mode “because they don’t evolve”, and a periodic node that read a static in its halo stepped about 3% off the unsharded node, with no error.)The inner node’s
update_paddedreceives the padded slab viastatic_padded[<key>]and uses it like any other padded array.
Declaring a sharded static array#
The declaration lives on the node:
from maddening.core.node import SimulationNode
from maddening.core.static_data import StaticArray
class WallBouncebackLBM(SimulationNode):
def __init__(self, name, timestep, *, nx, ny, nz, pipe_radius):
super().__init__(name, timestep,
nx=nx, ny=ny, nz=nz, pipe_radius=pipe_radius)
self._pipe_wall = _build_pipe_mask(nx, ny, nz, pipe_radius)
def halo_width(self):
# D3Q19: read ±1 neighbour on every spatial axis
return {0: 1, 1: 1, 2: 1}
@property
def static_data(self):
return {
"pipe_wall": StaticArray(
self._pipe_wall,
replication="shard",
shard_axis=0, # shard along the x axis
),
}
shard_axis is the array’s own axis — it must match one of the
spatial axes the wrapping ShardedStencilNode actually shards
(shard_axis ∈ axis_map.values()) and the node must declare
a non-zero halo_width() entry on that axis (otherwise there’s
no neighbour slab to exchange with). Both invariants are checked
at ShardedStencilNode.__init__ time:
sharded = ShardedStencilNode(
WallBouncebackLBM("lbm", 0.001, nx=128, ny=64, nz=64, pipe_radius=20),
mesh=mesh,
axis_map={"spatial_x": 0}, # shard axis 0 on mesh "spatial_x"
boundary="periodic",
)
# Raises ValueError immediately if pipe_wall's shard_axis isn't in
# axis_map.values() (i.e. it's not on the sharded spatial axis), or
# if halo_width() has no entry for shard_axis.
Replicate-mode StaticArray entries are not affected — they stay
closure-captured in full on every device, exactly as in v0.2.0.
Reading the sharded slab in update_padded#
The inner node’s update_padded gets a new keyword-only argument:
def update_padded(
self, state_padded, boundary_inputs, dt,
*, static_padded=None, shard_info=None,
) -> dict:
"""Receives a per-shard slab of pipe_wall via `static_padded`."""
f_pad = state_padded["f"] # (nx_local+2, ny, nz, 19)
wall_pad = static_padded["pipe_wall"] # (nx_local+2, ny, nz)
# interior view (halo stripped) is wall_pad[1:-1, :, :]
# cells outside the interior come from the neighbour shard's
# boundary cells via halo_exchange
...
The keyword is None when there are no sharded static_data
entries on the node, or when the node is run outside of
ShardedStencilNode (e.g. single-device path). Default to the
closure-captured full array in that case:
def update_padded(
self, state_padded, boundary_inputs, dt,
*, static_padded=None, shard_info=None,
):
if static_padded is not None and "pipe_wall" in static_padded:
wall = static_padded["pipe_wall"]
else:
# Unsharded path; the full wall is closure-captured on self.
# Pad it the way the wrapper would: this node is wrapped with
# boundary="periodic", so the halo beyond each x face is the
# opposite face ("wrap"); a node wrapped with "edge" or "zero"
# would use mode="edge".
wall = jnp.pad(self._pipe_wall, [(1, 1), (0, 0), (0, 0)], mode="wrap")
...
Declaring domain-integral outputs#
A node that computes a jnp.sum-over-lattice output (a drag
force, a total mass, a heat-flux integral) needs cross-device
reduction under sharding. Declare which output keys are
integrals:
def domain_integral_fields(self) -> set[str]:
return {"drag_force", "drag_torque"}
and have update_padded compute the partial sum on the local
slab — no psum in the node:
def update_padded(self, state_padded, boundary_inputs, dt,
*, static_padded=None, shard_info=None):
...
force_partial = jnp.sum(
per_cell_force * wall_pad[1:-1, :, :, None],
axis=(0, 1, 2),
)
return {
"f": f_new,
"drag_force": force_partial, # (3,) — partial sum on this shard
}
ShardedStencilNode applies
lax.psum(value, axis_name=tuple(mesh.axis_names)) to every key
listed in domain_integral_fields() after update_padded
returns, and sets the corresponding out_spec to P() (fully
replicated post-psum). Returned integral values are
floating-point — psum on integer types risks wrap on large
meshes.
Important
Every key returned from update_padded must be either in
state_fields() or in domain_integral_fields(). The wrapper
defensively raises ValueError at trace time on an unknown key
— it has no way to infer the partition spec for an unclassified
output.
A static copied along a mesh axis, inside a loop (MADD-ANO-068)#
On jaxlib 0.10.2 to 0.11.2, XLA compiles one kernel pattern wrongly when
the step is traced inside a loop. The step reads a sharded static in its
halo, reads another array through a window at a shard_info offset, and
the static is copied along a mesh axis of two or more devices. A mesh
axis the axis_map leaves unused copies it, and so does the other axis
of a pencil. The result is silently wrong on the first kind of axis and
fails to compile on the second. jit alone is right. GraphManager
therefore refuses any path that loops over such a step:
run_scan,run_scan_with_history,run_sweep,run_adaptive_scanandsysid.windowed_loss, gradients through them included;every entry point,
step()included, when the node is in a coupling group, because the group’s iteration loops over the update inside the step.
The RuntimeError names the node, the static and the mesh axis.
The decision comes from your update_padded itself. The graph traces it
once, never compiled, and walks the jaxpr. Each of the following keeps a
node out of the refusal:
read the static only in its interior, with a
slice(or a literaldynamic_slice) inside[halo, n - halo)along its shard axis;don’t window another array at a shard offset. A window into the static itself is fine: taking the static’s own columns at
shard_info[1]is how the multi-device validation goals’ 2-D field reads a full-width static on a pencil;build the mesh from the axes the node is sharded along only.
A loop you write yourself around a wrapped node, such as a lax.scan
over its update, is not guarded. Check it against step().
What’s still TODO#
~~Partial-axis psum.~~ Done in v0.4.0: override
domain_integral_axes()to map an integral key to the mesh axes it is reduced over ({"drag": ("spatial_z",)}); the result keeps one leading dimension per unreduced mesh axis, sharded along it, and an empty tuple stacks the per-shard partial values without reducing. A key without an entry is stillpsum-med over the full mesh. A mesh axis that splits nothing (one a stencil wrapper’saxis_mapleaves unused) is neither reduced nor stacked over: every device along it holds the same block. BothShardedStencilNodeandShardedUnstructuredNodehonour it (tests/cloud/multigpu/test_partial_axis_integrals.py).Sharded static arrays without halos. v0.2.1 rejects a sharded
StaticArraywhoseshard_axisdoesn’t appear innode.halo_width(). A future relaxation could allow such arrays by skipping the halo exchange, but the semantics get subtle near slab boundaries.
See also#
What’s new in v0.2.1 — release notes for the cycle this shipped in.
StaticArrayAPI reference — dataclass contract and hash semantics.Edge validation: migration guide (v0.2 → v0.3.0) — companion v0.2.1 change.