Accumulation#
Many producers need to land their contributions in the same per-sample slot — how do you add them up without a data race, and without forcing every producer onto its own private copy?
aether::accum answers that with two small, independent pieces: an
accumulation plane for “many writers, one running total per sample”
(Vector and matrix algebra cookbook-shaped terms summed into one vector), and
an append counter for “many writers, an unknown number of them, one
shared destination array” — both usable from a CUDA kernel or an OpenMP
loop through the exact same call spelling.
The problem: one thread per contribution, not one thread per sample#
Most of this library’s expression templates assume one thread (or OMP
task) owns a sample outright and writes it once. A force-model
accumulator breaks that assumption on purpose: several independent terms
(gravity, drag, solar pressure, …) are often cheaper to compute as
separate kernels, each contributing to the same per-sample acceleration.
Two threads adding to the same double with a plain += is a race,
and serializing every contribution defeats the reason the terms were
split apart in the first place. aether::AccumPlane exists to make that
+= safe without the caller reasoning about launch order.
Accumulation planes#
aether::AccumPlane<Real, VD, Policy> (aether/accum/AccumPlane.h,
alias of aether::accum::Plane) is an owning, per-sample accumulator of
VD lanes of Real (double is the only scalar currently
supported), constructed once from a sample count. plane.hostView()/
plane.deviceView() hand a kernel the non-owning
aether::AccumPlaneView it actually accumulates into — the only type a
device function ever touches — and zeroHost()/zeroDeviceAsync() on
the owning Plane clear the whole allocation before a step starts.
From inside a kernel or an OMP loop, view[i] returns a
aether::accum::LaneView — a thin, stateless handle over one sample,
cheap to create and discard on every write — and view[i] +=
gravityTerm (equivalently, the named view[i].accumulate(term)) folds
a VD-component expression term into that sample’s lanes, in ascending
component order so a host run and a device run accumulate the identical
bits. What the += actually does underneath depends on the plane’s
Policy:
aether::accum::CompensatedAtomic(the default fordouble) pairs a hardwareatomicAddwith a companion residual lane, combined throughaether::accum::atomic::compensatedSum— a Knuth two-sum, so the delivered value (value + comp) does not depend on the order concurrent producers happened to run in. Concurrency-safe by construction.aether::accum::Serializedis a direct+=with no atomic and no companion lane: cheaper, but the caller owes serialization of every write to one sample (a reduction already staged into one thread per sample, say).
Both policies read back through LaneView::delivered() / deliver(),
which fold the lanes into a register-resident aether::Item<Real, VD>
or write it straight into a destination expression. zero() — on the
view, per sample, or zeroHost()/zeroDeviceAsync() on the owning
Plane for the whole allocation — always clears both lanes
unconditionally: there is no “first producer” flag to get wrong on a
replayed step.
Variable-count producers: append counters#
A plane fits “the destination count is fixed, the contributor count
varies.” The complementary shape is “the DESTINATION count is unknown
ahead of time” — detecting candidate events, say, where only some samples
produce one. aether::accum::AppendCounter (aether/accum/
AppendCounter.h) hands out slots, one atomic fetch_add per claim,
over an Array’s reserved spare() capacity: a kernel calls
view.tryAppend() on the aether::accum::AppendCounterView
(counter.deviceView()) to claim the next slot, writes its candidate
there, and the host later calls counter.commit(arr) to adopt the
claimed slots into arr and reset the counter for the next step.
tryAppend() never returns a slot >= capacity; past that point it
sets an overflow flag and returns the aether::accum::npos sentinel, so
every accepted write is safe even when more producers claim a slot than
the array can hold. commit() is the one-line pattern that closes the
loop: read the raw count back, clamp it to capacity(), adopt that many
newly-written samples into the Array via its own commit(), and
reset the counter for the next step — never throwing itself, so a
rejected overflow does not discard the samples that did fit.
A runnable example#
TEST_F(AppendCounterTest, SequentialTryAppendFillsSlotsInOrder)
{
AppendCounter counter(8);
auto view = counter.deviceView();
for (std::size_t k = 0; k < 8; ++k) {
const aether::offset_t slot = view.tryAppend();
EXPECT_EQ(slot, static_cast<aether::offset_t>(k));
}
EXPECT_EQ(counter.rawCount(), 8u);
EXPECT_FALSE(counter.overflowed());
}
This runs on the CPU host path (AETHER_CPP_MODE, one OMP-visible
atomic per claim); tests/test_AppendCounter.cu exercises the identical
contract from a CUDA kernel with thousands of concurrent claimants,
checking that every slot in [0, capacity) is handed out exactly once.
Both AccumPlane and AppendCounter share that same promise: the
same call spelling, the same result, whether the producers are OpenMP
tasks or CUDA threads.