Loop Abstraction
The loop abstraction in src/loop_abstraction (header
loop_abstraction/loop_abstraction.hpp, namespace
parthenon::loop_abstraction) is a higher-level way to write block-structured
kernels than raw Nested Parallelism calls. It lets a kernel describe what
index space it iterates over and how variables are laid out, and then chooses an
efficient loop structure for the target backend at compile time.
It is a newer, more experimental interface than par_for/par_for_outer and is
primarily used by downstream applications with demanding reconstruction/flux kernels.
The precise semantic contracts each path must satisfy are recorded in
src/loop_abstraction/LOOP_ABSTRACTION_CONTRACTS.md; that document is the
authoritative reference for the invariants summarized here.
Warning
The loop-abstraction headers encode subtle index-space and scratch contracts.
Read LOOP_ABSTRACTION_CONTRACTS.md before changing them.
Overview
Two objects and two free functions form the core of the API:
IndexSpace<loop_tag, inner_tag, backend>describes the logical(block, k, j, i)iteration space and the memory layout of a block. The three template parameters fix the loop shape, the inner traversal, and the backend at compile time.InnerIndexRangeis one slice of anIndexSpace(a block plus the current chunk ofkjispace). It is the object handed to inner loop bodies and knows how to translate between flat, memory, and logical(k, j, i)indices.outer(idx_space, f)launches the outer loop. Its bodyf(idx_range, b)receives anInnerIndexRangeand the block indexb.inner(idx_range, g)runs the inner loop over one slice. Its bodygmay take either a single index (g(auto idx)) or explicit coordinates (g(int k, int j, int i)).
A minimal kernel looks like:
namespace la = parthenon::loop_abstraction;
using IndexSpaceType =
la::IndexSpace<la::loop_tag::bovi, la::inner_tag::logical_flat>;
IndexSpaceType idx_space(nblocks, nx, ny, nz, nghost);
la::outer(idx_space, KOKKOS_LAMBDA(const auto &idx_range, int b) {
la::inner(idx_range, [&](auto idx) {
const auto [k, j, i] = idx_range.GetKJI(idx);
// ... work at (b, k, j, i) ...
});
});
Body signatures
An inner body may be written as f(auto idx) or f(int k, int j, int i). When
both are viable the three-argument coordinate form is selected. The coordinate form
may cost some performance (the internal index is converted back to (k, j, i)
before the call) but is often clearer.
Halos
A halo is a compile-time annotation naming the neighboring produced values a consumer
loop needs. If a consumer inner loop runs over a logical point set S, then a
producer that fills scratch for the consumer must cover S plus the shifted copies
named by the halo:
AddHalo<halo_t<h1, h2, ...>>(S) == S ∪ shift(S, h1) ∪ shift(S, h2) ∪ ...
The common reconstruction-to-flux pattern is a producer that writes reconstructed states into scratch over a halo-extended range, followed by a consumer flux loop over the base range:
using recon_halo = la::halo::minus_i_t;
idx_space.AddPerPointScratch<Real, recon_halo>(); // at setup
const auto dx1 = idx_space.GetDelta(X1DIR);
la::outer(idx_space, KOKKOS_LAMBDA(const auto &idx_range, int b) {
const auto halo_range = idx_range.AddHalo<recon_halo>();
auto scratch = la::GetPerPointScratch<Real>(halo_range);
la::inner(halo_range, [&](auto kji) { scratch(kji) = reconstruct(kji); });
idx_range.TeamBarrier(); // producer must finish before the consumer reads
la::inner(idx_range, [&](auto kji) {
flux(kji) = riemann(scratch(kji - dx1), scratch(kji));
});
});
A halo is not the same as a reconstruction stencil width: the stencil is internal to computing one value, while the halo describes which produced neighbors must exist.
Scratch
Per-point scratch is registered on the IndexSpace at setup and requested inside
the outer body:
idx_space.AddPerPointScratch<Real>(); // one Real per point
idx_space.AddPerPointScratch<Real, 2, 3>(); // a 2x3 block per point
la::outer(idx_space, KOKKOS_LAMBDA(const auto &idx_range, int b) {
auto scratch = la::GetPerPointScratch<Real>(idx_range);
// scratch(idx), scratch(Index3{k,j,i}), scratch(c0, c1, idx), ...
});
The scratch object specializes per backend (compact per-cell storage for the
point-wise boiv path, a bump arena for the raw backend, Kokkos team scratch for
the Kokkos backend), but the user-facing indexing is uniform. As with raw nested
parallelism, call idx_range.TeamBarrier() between a producer inner loop and a
consumer that reads values written by other threads.
Pack and variable views
Views adapt a SparsePack to the current loop contract so variable access follows
the same index conventions as the loop body:
make_pack_view(idx_range, pack)– a view over a set of variable types.make_var_view(idx_range, pack, var)– a single-variable view.make_flux_pack_view(idx_range, pack, dir)/make_flux_view(...)– the flux-array counterparts, for one sweep direction.
Each view accepts the same index forms the body produces (flat int, Index3,
or explicit k, j, i), so a kernel can be written once and reused across inner
tags.
Backend selection
The third IndexSpace template parameter is the loop_backend:
loop_backend::raw– a plain host loop nest (with#pragma omp simd).loop_backend::kokkos– dispatch through Kokkos parallel policies.
It defaults to default_loop_backend_v, which is raw when the device execution
space is the host space and kokkos otherwise. outer/inner dispatch on this
tag with if constexpr, so the selection is zero-cost. Pinning the tag explicitly
is mostly useful in tests that want to exercise a specific backend regardless of the
build.