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
(somewhat) 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. The backend has a default that is raw for loops with simd markings on host and kokkos based loops on device.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 IST = la::IndexSpace<la::loop_tag::bovi, la::inner_tag::logical_flat>;
IST idx_space(ninner, IndexDomain::interior,
0, nblocks, md, TopologicalElement::CC);
la::outer(idx_space, KOKKOS_LAMBDA(const IST::idx_range_t &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) ...
});
});
Lambda markings follow the usual Kokkos hierarchical-parallelism rule: mark the
outer(...) body with KOKKOS_LAMBDA (it is stored and invoked on the device),
and leave the inner(...) bodies as plain [&] lambdas (they are defined inside
the outer device lambda, so they are already device code and capture by reference).
The outer body must name its parameter type rather than use auto: nvcc rejects
generic extended (__host__ __device__) lambdas. IST::idx_range_t is the
(base, no-halo) range outer hands the body; in code templated on the index space,
spell la::InnerIndexRange<IST> directly to avoid a dependent-name typename.
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.
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.
Scratch
Per-point scratch is registered on the IndexSpace at setup and requested inside
the outer body:
The scratch object specializes per loop pattern and backend (compact per-cell storage
for the point-wise boiv paths, and a host scratch for the raw backend or Kokkos team
scratch for the Kokkos backend for other paths), 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.
Reductions
outer_reduce/inner_reduce mirror outer/inner but fold a single Kokkos
reducer over the index space. They are Kokkos-only: they always dispatch to the
Kokkos backend regardless of the IndexSpace backend tag (on a host-only build the
device execution space is the host, so the Kokkos reduce still runs there), and
there is no raw reduction path.
The reducer is baked into the index-space type. Build a reduction space with
ReductionIndexSpace<lt, it, R> (which hides the backend template parameter) or by
rebinding an existing space with idx_space.WithReducer<R>(). Its idx_range_t
then carries the reduction, so the outer body is just (idx_range, int b) – no
handle to thread through. The preferred outer_reduce overload constructs the reducer
over a fresh result and returns it (the result is a host scalar, so the reduce is
synchronous and the value is valid on return, no fence needed):
using rist = la::ReductionIndexSpace<lt, it, Kokkos::Sum<Real>>;
rist idx_space(/* ... */);
auto result = la::outer_reduce(idx_space,
// Outer body param types must be named, not auto (nvcc rejects generic extended
// lambdas). The inner_reduce body is an ordinary lambda, so auto is fine there.
KOKKOS_LAMBDA(const rist::idx_range_t &idx_range, int b) {
la::inner_reduce(idx_range, [&](auto idx, auto &v) {
v += /* something at idx */;
});
});
An escape-hatch overload instead takes a caller-constructed reducer instance last
(matching Kokkos::parallel_reduce(policy, functor, reducer)) for reducing into a
View, ScatterView, or device memory; it returns void and its reducer type must
match the space’s reduction_t.
Because the reducer type lives on the index space, inner_reduce reuses its join op
without the caller restating it, and a single outer_reduce region may contain
several inner_reduce calls (interleaved with plain inner calls that only fill
scratch) that all join into one accumulator. There is one reducer per region. The
inner_reduce body takes the usual index form plus a trailing reduction-value
reference.
Two rules keep reductions off ghost cells:
No reductions over halo ranges.
inner_reducestatic_asserts that the range’s halo isnone_t. Extend a range only for producer (scratch)innerloops and reduce over the base, halo-free range.The
memoryinner tag degenerates tological_flat– but only forinner_reduce. Under a reduction thememorytag iterates logical cells rather than a contiguous memory span, so no swept ghost cell is folded in (the body still gets a memory-relative flat index, so call sites are unchanged). This is scoped strictly toinner_reduce: a plaininner()inside anouter_reduceregion behaves exactly as underouter()and does not degenerate – with thememorytag it still sweeps whole contiguous spans, ghosts included. Amemory-tag producer feeding aninner_reduceconsumer is therefore fine; just don’t assume the producer stayed inside the logical set.
Halos
The common reconstruction-to-flux pattern is a producer inner loop that writes reconstructed states into scratch over an extended range, followed by a consumer flux loop over the base range that accessess the scratch memory with offsets. The parthenon loop abstraction implements patterns like this through the concept of inner range halos. As an example, a simple flux calculation kernel in the loop abstraction might look like (see below for pack views used in the example):
More explicitly, 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) ∪ ...
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.
Pack and variable views
Views adapt a SparsePack to the loop abstraction so variable access follows
the same index conventions as the loop body:
make_pack_view(idx_range, pack)– a view over all non-sparse variables contained inpack.make_sparse_pack_view(idx_range, pack, sparse_idx)– a view over all sparse variables contained inpackat sparse indexsparse_index.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. Note that this is different from how sparse packs and variable packs work in Parthenon, where you can request fluxes from the pack and do it for any direction. Here the flux view only contains fluxes and only for the direction requested on construction.
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. In inner_tag::logical_coords loops, these are just light wrappers that call
through to the sparse packs. For all other `inner_tag`s, pack view construction directly
pulls out pointers to the variables. This can promote vectorization and be a significant
performance benefit.