Range-restricted, operator-restricted right-hand sides: tiles with halos, and lanes
Evaluate one operator's right-hand side — a subset of additive terms selected by provenance, writing only that operator's variables — over a box of cells (a horizontal tile with whole columns) that reads halo cells from an extended local array, or over a runtime list of cells gathered into lanes. Index space stays global, so makearray boundary regions apply exactly where the global domain has a boundary and interior stencils apply at every other tile edge. The contract is bitwise: the tiles' outputs, assembled, equal the operator's global right-hand side bit for bit.
Status: draft (proposed by EarthSciIntegrator, 2026-10-13); done upstream: released in EarthSciAST v0.5.0 (EarthSciAST#557)
Target: EarthSciAST API_SPEC §5.8 (new entries beside esm_problem; no schema or format-version change); EarthSciAST.jl and earthsci-ast-rs v0.5.0 (released); Python later.
Needed by: EarthSciIntegrator milestone M3 (tiles in shared memory, and lane pools), then M5 (MPI ranks).
Integrator refs: architecture.md §3.2 (passes 2, 3, 7, 8), §7.1, §7.2, §7.6, §7.7, §13 (U3); decisions D6, D7, D10, D11, D14, D17, D20, decisions.md §2.2
Summary
The integrator splits F = Σ_k F_k into operators by provenance (U2) and runs
each operator on the partition its locality calls for (architecture §7.2):
stencil operators on geographic tiles with per-stage halo exchange, and column
and pointwise operators on lanes. Today both bindings evaluate only the whole
model over the whole domain. On top of U4’s program type, this RFC adds:
- Operator selection. Keep only the summands whose U2 provenance is
selected and write only the operator’s variables
W_k. The other states it reads,R_k \ W_k, arrive as a separate read-only input. - Tiles. Restrict that program to an owned box
Bof a horizontal decomposition. It reads from a storage boxS ⊇ Bthat includes the halo and writes only the(W_k, B)rows ofdu. Indices stay global, so amakearrayboundary region applies only where its global cells fall insideB. - Lanes. Evaluate a pointwise or column operator over a run-time list of cells or columns. Each lane carries its global index, from which index-derived quantities (coordinates, level gates) are computed.
The normative test: assembling every tile’s owned rows reproduces the
operator’s global right-hand side bitwise under native in each binding,
and every lane reproduces its cell’s value bitwise.
Motivation
Nothing is range-restricted or operator-restricted today.
- Julia builds one whole-domain
f!(EarthSciAST/pkg/EarthSciAST.jl/src/simulate.jl:1189-1207;src/tree_walk/acc_merge.jl:635-754).- When array observeds exist, every call copies all of
uinto an extended vector and fills every observed level over every cell (build.jl:2757-2772). - Its only cell partition is the Polyester chunking. That is ordinal, not
spatial, and covers kernels only (
access_kernel.jl:1311-1323).
- When array observeds exist, every call copies all of
- Rust transposes every state variable into a row-major mirror on every call
(
earthsci-ast-rs/src/simulate_array/tape/exec/mod.rs:530-548). Each instruction then runs over its slot’s whole static box (tape/ir.rs:933-945). - Julia never zeroes
du: writes are assignments (oop_merge.jl:54-57). Rust zeroes all of it (driver.rs:621-623). Tiles sharing oneduneed a rule for what a restricted program may touch. - Flatten sums
operator_composeterms into one+node per target (src/coupling_apply.jl:429-432). EarthSciASTSplitter’s parts are full-length (decisions.md §3.2, item 6).
A tile cannot be a smaller model.
- Out-of-range reads are decided at build time, against the extent of the array
read. A state gather outside its variable’s box reads a zero ghost:
- Julia
compile.jl:190-198,build.jl:427-433; - Rust
tape/ir.rs:26-28; - CONFORMANCE_SPEC fixes this for all bindings (
CONFORMANCE_SPEC.md:884-886).
- Julia
- Boundary conditions live inside
Fasmakearrayregions, later regions overwriting earlier ones (esm-spec.md:4565,:1075). - EarthSciDiscretizations carves one region per boundary row.
- PPM’s cell form is
[4, N-2]plus slabs 1, 2, 3, N-1 and N: “three wrap columns at the low end and two at the high end” (EarthSciDiscretizations/grids/latlon3d/rules/ppm_D_lon_periodic.esm:5, regions from:78). - The periodic slabs wrap by explicit index arithmetic:
ppm_D_lon_wrap_col1.esm:49-50readsf[NLON-1]andf[NLON]from cell 1.
- PPM’s cell form is
- So a tile re-instantiated as its own model reads zeros at interior edges, or applies boundary rules there. Either way it does not match the global run (decisions.md §2.2).
- reseact’s transport is built from exactly these rules
(
reseact.esm/reseact.esm:75,:91).
reseact’s lanes need surgery and one build per capacity.
- Its capacity build re-instantiates chemistry on a
C × 1 × 1grid (reseact.esm/tools/capacity_chem.jl:59-66). - Of the 310 names chemistry needs, 37 read a raw loop index. Eight of those
need document surgery because “the index itself carries the meaning”
(
:12-29). - Builds cost 4.6 s at C = 288 and 11.4 s plus a 46.5 s Jacobian at C = 4096,
against 650.2 s plus 567.8 s for the full CONUS reference (
:31-46). - A ladder of five capacities keeps padding waste near 3% (
:48-57). - Padding lanes must duplicate a real cell, or the error norm goes NaN
(
:353-364).
The compiler already knows every one of these index uses, so it can compute them per lane.
Decisions of record
Proposed by the integrator, for the maintainers to rule on.
| # | Question | Proposal | Why |
|---|---|---|---|
| 1 | Index space of a tile | Global. Boxes, region bounds, coordinates and index-derived values all keep their global 1-based indices; only storage is local. | Boundary regions then apply exactly where the global domain has a boundary, and index-derived values need no rebasing. |
| 2 | Contract | Bitwise against the same operator’s global evaluation, same binding and compiler | D10. The kernels are per-element pure in both bindings (Julia: “NO @simd, NO @fastmath, NO muladd”, codegen_kernel.jl:19-20). |
| 3 | Out-of-range reads | Classified against the global extent. Out of domain keeps today’s semantics. In domain but outside storage is a build error, never a ghost zero. | A silent zero at an interior tile edge is the failure mode this RFC exists to prevent. |
| 4 | Intermediate observeds | Recomputed on their need box, not exchanged | One halo exchange per stage (architecture §7.2); the observed chains are short. |
| 5 | Halo filling | The caller’s job. The compiler reports the halo each tile needs. | The integrator owns exchange and MPI (D6). |
| 6 | Boxes | Fixed at build for tiles. Cell lists are run time for lanes. | Tile geometry changes only at checkpoints (architecture §7.6); shard composition changes every macro step. |
| 7 | Faces | Cell i owns face i, its low face. The tile that owns cell N also owns face N+1. | EarthSciDiscretizations: “FACE i IS THE LOWER (WESTERN) EDGE OF CELL i” (ppmflux_D_lon_interior.esm:5). This also fixes the meaning of architecture §7.1’s “owned by the low-side cell”. |
| 8 | What tiles refuse | Writes to 0-d states, and contractions, scans or data-dependent gathers along a decomposed axis | These are global locality (architecture §3.2, pass 2). |
Design
1. Terms
- Decomposition. The caller names the horizontal index sets it cuts, each
one’s period if periodic, and the face index sets that stagger off it (for
example
lon_nodesofflon). Every other index set (lev, species, records) is whole on every tile (D20). - Owned box
B. One inclusive range per decomposed index set. - Storage box
S.Bdilated by the halo. Along a non-periodic axis it may extend past1..N. Along a periodic axis it is expressed in unwrapped coordinates, for example-2..16. - Face sets. Their ranges follow from rule 7: owned faces are the owned
cells, plus
N+1at the high edge; storage faces areS’s cells plus one. A face quantity stored on the cell index set is just a cell variable. An example is eqice’sQx, “flux through the EAST face of each cell” (eqice/stage2/geometry.esm:7317-7324). - Rank storage. Several tiles of one rank may share one storage box: the
rank’s extended array. With
Sequal to the whole domain, storage is the global layout. That is the shared-memory case of architecture §2.2, where the halo exchange is a barrier.
2. Operator selection
select(prog; writes, terms) returns a program for one operator:
termsis a set of U2 provenance tags. A summand of aD(x)right-hand side is kept iff its tag is in the set. The kept summands are added in their original relative order.writes = W_k. Rules for variables outsideW_kare dropped.- Inputs.
ucoversW_k.auxcoversR_k \ W_k, the states the kept terms read; it is read-only and frozen for the leaf. Both use the flat-layout order (scalars, then arrays by sorted name;src/tree_walk/state_layout.jl:1-16). - Derived documents. When the planner emits a per-operator document
(architecture §3.2, pass 6), selection is
terms = allandwrites = W_k. Both routes give the same program. - Bitwise reference. Every bitwise claim below is against
select(prog; …)over the whole domain.Σ_k F_k = Fholds only to tolerance, because dropping summands changes the association; the planner checks it (§3.2, pass 8).
3. Halo report
halo(op, dec) returns, for every variable v ∈ R_k and every decomposed or
face index set s on v’s shape, the pair (lo, hi). This is how far the
owned output reaches into v along s.
- Composition. Offsets compose through observeds. If
du[i]readsF[i]andF[i+1], andF[j]readsu[j-3..j+2], thenuneeds(3, 3). - Region reach. Each
makearrayregion contributes its own reach. The report is the maximum over regions, so a reach that occurs only in boundary slabs is included. - Forcing and array parameters.
forcing_reach(op, dec)maps the owned box affinely to each buffer’s index box, including buffers on another grid such as GEOS-FP native arrays read atLON0+gi(capacity_chem.jl:20-25). A pre-sliced buffer “is a rebasing problem and nothing more” (reseact.esm/INDEX_RANGE_READS.md:66-72). - Wraps and refusals. Periodic wraps count as reach across the seam,
whether the evaluator’s periodic gather does them or the rule spells them out
(
f[NLON-1]from cell 1). A data-dependent subscript along a decomposed axis (RustIndexGather,tape/ir.rs:303-314) is refused, naming the rule. - Cross-check. The planner computes the same report from the AST (pass 2). The plan golden compares the two.
4. Tile semantics (normative)
Let G_k be the operator program over the whole domain. Suppose u_S and
aux_S agree with the global state on every in-domain cell of S, and on a
periodic axis every cell outside 1..N holds the value at its wrapped index.
Then for every v ∈ W_k and every owned cell c of v:
tile(u_S, aux_S, p, t, forcing)[v, c] == G_k(u, aux, p, t, forcing)[v, c] (bitwise)
The compiler reaches this by the following rules.
- Region clipping. Each
makearrayregion’s global box is intersected with the need box of its output. Empty intersections are dropped. Order, and “later overwrites earlier”, are kept. A PPM tile owninglon ∈ 17..32keeps only the interior[4, N-2]; the tile owning1..16keeps slabs 1, 2 and 3 and the interior4..16. - Reads. Each read of
vat global indexgalong axisaresolves in this order:gin storage → read storage.aperiodic with periodN, andg ± Nin storage → read that cell. This covers both explicit wraps and the periodic gather policy.goutside1..N,anot periodic → today’s out-of-range rule: zero ghost for states and observeds, the declared policy for const arrays (build_helpers.jl:195-216).- Otherwise → build error
range_halo_insufficient, naming the rule, the variable, the axis and the missing cells.
- Observeds. Each array observed is evaluated on its need box. That box is the union of its readers’ need boxes, each widened by that reader’s offsets, clipped to the observed’s global extent. It is recomputed by every tile that needs it.
- Writes. Only the
(W_k, owned)rows ofduare written. Every other element ofduis left untouched. Tiles of one rank may therefore write one sharedduconcurrently. - Dead halo cells. Storage cells outside
1..Non a non-periodic axis are never read. The caller may leave them uninitialized; the conformance test fills them with NaN.
5. Tile layout and signatures
u_S,aux_Sanddu_Sare species-major. Each variable is one column-major block over its storage box (first index fastest), in flat-layout order. This is today’s layout withSin place of the global box.du_Shasu_S’s layout; only owned rows are written.Index offsets are rebased by storage origin alone:
slot = base_v + Σ_d (g_d - S_lo[v,d]) · stride[v,d]Julia’s generated kernels already address
u[oln + Δ]with bounds, base and strides read from a run-timegeotable (codegen_kernel.jl:315-325), so only the table changes.
Julia
dec = Decomposition(axes = ["lon" => (periodic = false,), "lat" => (periodic = false,)],
faces = ["lon_nodes" => "lon", "lat_nodes" => "lat"])
op = select(rhs_program(prob); writes = W, terms = tags) # U4 program type
h = halo(op, dec) # Dict{(var, set) => (lo, hi)}
tile = restrict(op, dec; owned = ("lon" => 17:32, "lat" => 1:13),
storage = ("lon" => 14:35, "lat" => -2:16))
lay = tile_layout(tile) # blocks, bases, strides, owned rows per variable
ws = rhs_workspace(tile)
rhs!(du_S, tile, ws, u_S, aux_S, p, t, fs) # fs: U4 ForcingSet, windows allowed
Rust
pub struct Decomposition { pub axes: Vec<AxisCut>, pub faces: Vec<(String, String)> }
pub struct AxisCut { pub index_set: String, pub period: Option<i64> }
pub struct TileBox { pub owned: Vec<(String, RangeInclusive<i64>)>,
pub storage: Vec<(String, RangeInclusive<i64>)> }
impl RhsProgram { // U4
pub fn select(&self, sel: &RhsSelection) -> Result<RhsProgram, SimulateError>;
pub fn halo(&self, dec: &Decomposition) -> Result<HaloReport, SimulateError>;
pub fn tile(&self, dec: &Decomposition, b: &TileBox) -> Result<TileProgram, SimulateError>;
pub fn lanes(&self, kind: LaneKind, capacity: usize) -> Result<LaneProgram, SimulateError>;
}
impl TileProgram { // Send + Sync (U4)
pub fn layout(&self) -> &TileLayout;
pub fn new_workspace(&self) -> RhsWorkspace;
pub fn eval_into<T: StateScalar>(&self, u: &[T], aux: &[T], p: &[f64], t: f64,
forcing: &ForcingSet, du: &mut [T], ws: &mut RhsWorkspace) -> Result<(), RhsError>;
}
In Rust a tile is a re-lowering of the tape. Slot boxes become need boxes.
GatherPlan segments are computed against storage-box sources, with ghost
classification against global extents (tape/ir.rs:961-982). Region and
Assemble clip. DyWrite targets only the owned rows of the storage box. The
state mirror covers R_k ∩ S only. Ramp keeps its absolute lo.
A ForcingSet slot may be a window with a per-axis origin. The build checks
that the window covers forcing_reach.
6. Lanes
lanes(op, kind, capacity), for operators whose locality is pointwise
(kind = Cells) or column (kind = Columns).
Inputs and layout.
cellsis ani32array of global indices,[D, L], withL ≤ capacity.Dis every spatial index set of the output forCells, and the horizontal sets only forColumns.u_L,aux_Landdu_Lare species-major with the lane index fastest: pointwise slot(s-1)·L + l(reseact’sNSruns ofCcells), column slot((s-1)·nlev + (k-1))·L + l. Every per-cell or per-level kernel, and every batched block solve (architecture §6.1), then vectorizes across lanes.
Semantics (normative): for each lane l and each v ∈ W_k,
du_L[v, l] == G_k(…)[v, cells[:, l]] bitwise. A lane’s result depends only on
its own inputs. Consequences:
- Padding a batch with duplicates of a real cell is safe and discardable. This
is reseact’s rule (
capacity_chem.jl:353-364). - Lane order and batch composition cannot change a value. That is the property lane pools need (architecture §7.6).
Reads.
- State reads at offset 0 (pointwise) or along the column (column) come from
u_Landaux_L. - Every read that depends on the cell’s index is evaluated from
cells: coordinate expressions (lon0_deg + (gi-1/2)·dlon_deg), level gates (gk < 2), and affine reads of forcing, array parameters, const arrays and state-free observeds (GEOSFP.T[r, gk, LAT0+gj, LON0+gi]). These are reseact’s 37 index-reading names: no surgery, and no rebuild per composition. - State-free observeds keep their whole-domain build-time values and are gathered per lane. NEI’s conservative regrid is computed once per model, not once per capacity.
Pre-gathered inputs (optional). lane_inputs(lanes) returns the gather
plan for every non-state input. The integrator can then fill per-shard lane
buffers once per epoch with gather_lane_inputs! and call the variant that
reads them. This is reseact’s gather_forcing! (capacity_chem.jl:414-439),
moved into the compiler.
Capacity.
- Julia loops over lanes with a run-time count, so one build serves every
L. - Rust slot boxes are static, so a lane program is built per capacity
(re-lowering only, no document rebuild). A call passes
n ≤ capacity; lanesn+1..capacityare computed and unspecified. - The XLA lane program is U13’s.
Rust signature.
impl LaneProgram {
pub fn eval_into<T: StateScalar>(&self, u: &[T], aux: &[T], p: &[f64], t: f64,
forcing: &ForcingSet, cells: &[i32], n: usize,
du: &mut [T], ws: &mut RhsWorkspace) -> Result<(), RhsError>;
}
Julia signature. rhs!(du_L, lanes, ws, u_L, aux_L, p, t, fs, cells, n).
Python. restrict and lanes raise compiler_unavailable until the Python
native implementation needs them (D1).
What this is not
- Not halo exchange, MPI or threading. The integrator does those (D6). The compiler only reports the halo.
- Not run-time tile boxes. A tile is rebuilt when geometry changes, which happens at checkpoint boundaries only.
- Not
globaloperators. SSA, connected components and horizontal sums stay whole-domain (architecture §7.2). - Not a schema change. The decomposition is an API argument.
ModelVariable.locationexists (src/types.jl:1076-1078) but is not needed. - Not band models or XLA. Jacobian and VJP band models are derived documents compiled by this same path (U7, U8). XLA is U13.
Conformance and tests
Tile partition (normative, bitwise). For every stencil fixture, with decompositions 1×1, 2×1, 3×2 (uneven tiles), and one tile narrower than the boundary band (owned
1..2against PPM’s three low slabs):- fill each tile’s storage from the global state and dead halo cells with NaN;
- scatter the owned rows into one vector and assert it equals
G_kbitwise, in Julia and in Rust; - assert every element outside the owned rows of a NaN-prefilled
duis untouched.
Across bindings, agreement is the CONFORMANCE_SPEC §5.9 band.
Boundary regions. The EarthSciDiscretizations 1-D and latlon3d PPM fixtures: periodic (explicit wraps), inflow with halo operands (
ppm_flux_D_lon_mono_inflow_bc), zero-gradient, no-flux, and a face field[lon_nodes, lat, lev]read atiandi+1.Halo report.
haloequals the planner’s AST-derived report. A storage box one cell short raisesrange_halo_insufficient.Lanes. Random permutation, duplicates and padding: bitwise per cell. Negative control: swapping two lanes’ inputs must mismatch (reseact’s version reports 7.6e10,
capacity_chem.jl:34-38).Selection.
Σ_k G_kagainst the fullF, within the planner’s tolerance. An operator whosewritesomit a variable that its terms write is refused.
Work breakdown
- Selection (both bindings). Keep U2 tags on summands through
coupling_apply.jl:429-432. Drop unselected summands and rules. Add theauxlayout. - Need-box analysis (both). Compose offsets through observed levels and regions. Produce the halo and forcing-reach reports.
- Julia tiles. Intersect affine box kernels (
stencil_affine.jl:1376-1483) before the class merge (oop_merge.jl:1105-1135), or filter merged kernels' lane tables per tile (access_kernel.jl:779-784). Restrict observed fills, prelude tiers and scan folds. Rebase thegeotables. - Rust tiles. Re-lower over need boxes (§5). Restrict the state mirror.
- Lanes (both). Lower index-derived reads to gathers from
cells. Add the optional pre-gather plan. - Fixtures and tests (both).
Alternatives considered
- A tile as its own small model, like reseact’s capacity builds and eqice’s frozen-ghost blocks. It applies boundary rules or zero ghosts at interior edges, needs a build per tile, and needs document surgery.
- Evaluate the global
Fand discard. O(domain) work per tile per stage, with no MPI. - Local index space (every tile starts at 1). It breaks boundary-region identity and every index-derived value. Global indices with a storage offset cost nothing.
- Exchange intermediate observeds. That means more exchanges per stage, and their halos are then halos of halos.
- Run-time box arguments. Julia almost has them already (
geotables). Rust bakes them intoGatherPlans. This is deferred; the build-time box is enough for checkpoint-boundary geometry changes.
Open questions
- Should
termsselection live here or in U2? - Rust re-lowering cost per tile and per capacity is unmeasured (assumption: seconds). If it is large, tiles of one class (same shape, same edge flags) should share a program with a run-time origin.
- Must a face set’s relation to its cell set (
lon_nodestolon) be declared by the caller, as here, or should esm or EarthSciDiscretizations declare it? - Should restricted observeds be exportable for output on a tile, or only through U6’s observed kernels?
- Rust and Python infer a
makearray’s origin differently: Rust uses the minimumlo(earthsci-ast-rs/src/simulate_array/eval.rs:5178-5199), Python uses 1. Global index space (decision 1) settles it for tiles. Should the spec settle it for whole-domain programs too?