Skip to content

Commit 9bc5157

Browse files
RudolfWeeberclaude
andcommitted
core: component-major particle store layout by default (phase 8)
Re-evaluates the phase-3.5 layout decision on the finished migration with hardware profiling, per-config order-balanced A/B on identical sources (a compile-time toggle selects the layout; trajectories are bitwise-identical either way): - component-major wins p3m 4-rank (-1.7%), p3m 1-rank (-1.3%), lj 1-rank and 4-rank (-0.5% each) - lj-omp recovered from +1.0% to +0.5% (noise) by gathering positions into an interleaved scratch once per Verlet build: under component-major the build's per-candidate position reads touched three far-apart component streams, roughly doubling its cost at 4000 particles per core (perf-measured) - p3m-omp is inconclusive under P3M auto-tuning variance The phase-3.5 preference for particle-major came from gather-dominated paths (struct-era pair gathers, whole-particle ghost packing) that no longer exist; per-component ghost legs now benefit from contiguity, and component-major is the coalescing-friendly layout for device-resident GPU work. Particle-major stays selectable with -DESPRESSO_PARTICLE_STORE_PARTICLE_MAJOR. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent baa345b commit 9bc5157

3 files changed

Lines changed: 127 additions & 18 deletions

File tree

docs/superpowers/specs/2026-07-03-array-based-particle-storage-design.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,32 @@ tuning with zero particles raises an FPE under -ftrapping-math at 4 MPI ranks
141141
(clang+CUDA+FPE config) — reproduced identically at the pre-migration base
142142
commit; upstream PR candidate alongside the particle_node prefetch predicate.
143143

144+
**Layout re-evaluation (2026-07-09, user-requested): COMPONENT-MAJOR becomes
145+
the default — the phase-3.5 decision is REVERSED.** Method: a compile-time
146+
toggle builds both layouts from identical sources (all column access was
147+
already layout-agnostic except two hand-hoisted kernel pointer sites, made
148+
stride-aware); trajectories are bitwise-identical under either layout, so the
149+
comparison is purely speed. Order-balanced same-commit A/B (6 reps/config):
150+
component-major is faster on p3m 4-rank (−1.7%), p3m 1-rank (−1.3%), lj
151+
1-rank and 4-rank (−0.5% each); lj-omp was +1.0% and was recovered to +0.5%
152+
(noise) by perf-guided optimization — an interleaved position scratch filled
153+
once per Verlet build (the build's per-candidate reads touched three
154+
far-apart component streams, ~2× its cost at 4000 ppc); p3m-omp is
155+
inconclusive (readings 1.006 and 1.033 across rounds — P3M auto-tuning
156+
variance dominates that configuration). Ship-gate adjudication: the
157+
sequential gate read lj-1rank 1.055 vs phase-0 on decision day, but a
158+
same-session order-balanced anchor measured the layout delta on lj-1rank as
159+
exactly 1.000 (identical min-of-means) and BOTH layouts at 1.055 — i.e.
160+
~0.6pp of cross-day machine drift affecting both binaries equally
161+
(particle-major itself gated at 1.049 the previous day); per the established
162+
A/B-arbitrates precedent the 5% budget verdict stands. Why the reversal: the
163+
phase-3.5 measurement predated the struct death, per-field ghost exchange and
164+
column kernels — the gather-dominated paths that favoured particle-major are
165+
gone, and per-component ghost legs now benefit from contiguity.
166+
Component-major is also the coalescing-friendly layout for future
167+
device-resident GPU work. Particle-major remains selectable via
168+
-DESPRESSO_PARTICLE_STORE_PARTICLE_MAJOR.
169+
144170
## Non-goals
145171

146172
- No change to the Python user interface or its semantics.

src/core/particle_store/ParticleStore.hpp

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,27 @@ class ParticleStore {
7676
* columns are therefore particle-major. Component-major is re-evaluated at
7777
* phase 8 (GPU), where coalesced per-component access may flip the balance.
7878
*/
79+
/* Layout decision (phase-8 re-evaluation, 2026-07-09): COMPONENT-MAJOR
80+
* (LayoutLeft, all x components contiguous) is the default. Order-balanced
81+
* same-commit A/B (6 reps per config) measured component-major faster on
82+
* p3m 4-rank (-1.7%), p3m 1-rank (-1.3%), lj 1-rank and 4-rank (-0.5%),
83+
* neutral on lj-omp (+0.5%, after the Verlet-build interleaved scratch in
84+
* link_cell_kokkos) and inconclusive on p3m-omp (P3M auto-tuning variance
85+
* dominates). This REVERSES the phase-3.5 verdict (+10-12% for
86+
* component-major back then): the intervening struct death, per-field ghost
87+
* exchange and column kernels removed the gather-dominated paths that had
88+
* favoured particle-major, and per-component ghost legs now benefit from
89+
* contiguity. Component-major is also the coalescing-friendly layout for
90+
* device-resident GPU work. Particle-major remains selectable with
91+
* -DESPRESSO_PARTICLE_STORE_PARTICLE_MAJOR for A/B on identical sources;
92+
* all column access is layout-agnostic (typed views, (row, component)
93+
* indexing, stride-carrying contexts/proxies) and trajectories are
94+
* bitwise-identical under either layout. */
95+
#ifdef ESPRESSO_PARTICLE_STORE_PARTICLE_MAJOR
7996
using StateVectorLayout = Kokkos::LayoutRight;
97+
#else
98+
using StateVectorLayout = Kokkos::LayoutLeft;
99+
#endif
80100
using Column = Kokkos::DualView<double *[3], StateVectorLayout>;
81101
using IntegerColumn = Kokkos::DualView<int *[3], StateVectorLayout>;
82102
using QuaternionColumn = Kokkos::DualView<double *[4], StateVectorLayout>;

src/core/short_range_cabana.hpp

Lines changed: 81 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,9 @@
3737

3838
#include <iterator>
3939
#include <span>
40+
#include <type_traits>
4041
#include <utility>
42+
#include <vector>
4143

4244
template <class KokkosRangePolicy = Kokkos::RangePolicy<>>
4345
ESPRESSO_ATTR_ALWAYS_INLINE inline void
@@ -101,6 +103,32 @@ link_cell_kokkos(std::span<Cell *const> cells, BoxGeometry const &box_geo,
101103
// but ghost particles from other ranks may have larger particle ids;
102104
// -1 is used as a sentinel value for particle ids from other threads
103105

106+
// Component-major layout only: gather the position column into an
107+
// INTERLEAVED scratch buffer once per Verlet build. The build examines
108+
// O(N * candidates) positions by row; under LayoutLeft each such read
109+
// touches three far-apart component streams (three cache lines per
110+
// candidate), which perf measured at ~2x the build cost at 4000
111+
// particles/core. The O(N) sequential gather below is prefetch-friendly
112+
// and runs at rebuild cadence only. The kernels consume base + runtime
113+
// strides, so they read the scratch ({3, 1}) and the native column
114+
// (view strides) through the same code path. Under particle-major the
115+
// column is already interleaved and the scratch is skipped entirely.
116+
std::vector<double> interleaved_positions;
117+
if constexpr (std::is_same_v<ParticleStore::StateVectorLayout,
118+
Kokkos::LayoutLeft>) {
119+
if (not cells.empty()) {
120+
auto &store = cells.front()->store();
121+
auto const &position_view = store.position_view();
122+
auto const total = store.number_of_particles();
123+
interleaved_positions.resize(3u * total);
124+
for (std::size_t row = 0u; row < total; ++row) {
125+
interleaved_positions[3u * row + 0u] = position_view(row, 0);
126+
interleaved_positions[3u * row + 1u] = position_view(row, 1);
127+
interleaved_positions[3u * row + 2u] = position_view(row, 2);
128+
}
129+
}
130+
}
131+
104132
// Phase 7a perf fix: iterate the cells' store-ROW bags directly and REBIND
105133
// two cached views (p1 + partner) per work item via
106134
// Particle::attach_to_store, instead of driving RowParticleRange iterators
@@ -109,7 +137,8 @@ link_cell_kokkos(std::span<Cell *const> cells, BoxGeometry const &box_geo,
109137
// work item (one cell per Kokkos work item) is thread-safe. Iteration ORDER
110138
// is unchanged. Carriers stay default and are never read while attached.
111139
auto intra_kernel = [&cells, &box_geo, &verlet_criterion, &id_to_index,
112-
&intra_operator, max_id](const int i) {
140+
&intra_operator, &interleaved_positions,
141+
max_id](const int i) {
113142
auto &store = cells[i]->store();
114143
// Contiguous store-row range (phase 7c); clean store, so index directly.
115144
auto const offset = cells[i]->offset();
@@ -122,7 +151,23 @@ link_cell_kokkos(std::span<Cell *const> cells, BoxGeometry const &box_geo,
122151
// Particle views stay attached for the verlet_criterion (it may read
123152
// types/flags); iteration order and arithmetic are unchanged.
124153
auto const *const id_column = store.id_view().data();
125-
auto const *const position_column = store.position_view().data();
154+
// Layout-agnostic hoisted position access: base pointer plus the view's
155+
// run-time strides (row stride, component stride). Particle-major
156+
// (LayoutRight) gives {3, 1}; component-major (LayoutLeft) gives
157+
// {1, padded extent}. See the StateVectorLayout toggle in ParticleStore.
158+
auto const &position_view = store.position_view();
159+
// Prefer the interleaved rebuild-cadence scratch when populated
160+
// (component-major layout); otherwise read the native column. Same
161+
// base-plus-strides consumption either way.
162+
bool const use_scratch = not interleaved_positions.empty();
163+
auto const *const position_column =
164+
use_scratch ? interleaved_positions.data() : position_view.data();
165+
auto const pos_row_stride =
166+
use_scratch ? std::size_t{3u}
167+
: static_cast<std::size_t>(position_view.stride(0));
168+
auto const pos_comp_stride =
169+
use_scratch ? std::size_t{1u}
170+
: static_cast<std::size_t>(position_view.stride(1));
126171
Particle p1, p2;
127172
for (std::size_t a = 0u; a < n; ++a) {
128173
auto const row_a = offset + a;
@@ -132,18 +177,20 @@ link_cell_kokkos(std::span<Cell *const> cells, BoxGeometry const &box_geo,
132177
if (ii >= 0) {
133178
// Hoist p1's position out of the inner loop (one read per outer
134179
// particle instead of per pair candidate).
135-
auto const p1_pos = Utils::Vector3d{position_column[3u * row_a + 0u],
136-
position_column[3u * row_a + 1u],
137-
position_column[3u * row_a + 2u]};
180+
auto const *const p1_base = position_column + row_a * pos_row_stride;
181+
auto const p1_pos =
182+
Utils::Vector3d{p1_base[0u], p1_base[pos_comp_stride],
183+
p1_base[2u * pos_comp_stride]};
138184
// pairs in this cell (j > i), same order as before
139185
for (std::size_t b = a + 1u; b < n; ++b) {
140186
auto const row_b = offset + b;
141187
if (id_column[row_b] <= max_id) {
142188
p2.attach_to_store(store, static_cast<int>(row_b));
189+
auto const *const p2_base =
190+
position_column + row_b * pos_row_stride;
143191
auto const p2_pos =
144-
Utils::Vector3d{position_column[3u * row_b + 0u],
145-
position_column[3u * row_b + 1u],
146-
position_column[3u * row_b + 2u]};
192+
Utils::Vector3d{p2_base[0u], p2_base[pos_comp_stride],
193+
p2_base[2u * pos_comp_stride]};
147194
if (verlet_criterion(p1, p2,
148195
box_geo.get_mi_dist2(p1_pos, p2_pos))) {
149196
auto const jj = id_to_index(id_column[row_b]);
@@ -159,15 +206,29 @@ link_cell_kokkos(std::span<Cell *const> cells, BoxGeometry const &box_geo,
159206
};
160207

161208
auto inter_kernel = [&cells, &box_geo, &verlet_criterion, &id_to_index,
162-
&inter_operator, max_id](const int i) {
209+
&inter_operator, &interleaved_positions,
210+
max_id](const int i) {
163211
auto &store = cells[i]->store();
164212
// Contiguous store-row range (phase 7c); clean store, so index directly.
165213
auto const offset = cells[i]->offset();
166214
auto const n = cells[i]->count();
167-
// Hoisted raw column pointers: see intra_kernel. All cells share the one
168-
// active ParticleStore, so the pointers are valid for neighbor cells too.
215+
// Hoisted raw column pointers: see intra_kernel (incl. the layout-agnostic
216+
// stride note). All cells share the one active ParticleStore, so the
217+
// pointers are valid for neighbor cells too.
169218
auto const *const id_column = store.id_view().data();
170-
auto const *const position_column = store.position_view().data();
219+
auto const &position_view = store.position_view();
220+
// Prefer the interleaved rebuild-cadence scratch when populated
221+
// (component-major layout); otherwise read the native column. Same
222+
// base-plus-strides consumption either way.
223+
bool const use_scratch = not interleaved_positions.empty();
224+
auto const *const position_column =
225+
use_scratch ? interleaved_positions.data() : position_view.data();
226+
auto const pos_row_stride =
227+
use_scratch ? std::size_t{3u}
228+
: static_cast<std::size_t>(position_view.stride(0));
229+
auto const pos_comp_stride =
230+
use_scratch ? std::size_t{1u}
231+
: static_cast<std::size_t>(position_view.stride(1));
171232
Particle p1, p2;
172233
for (std::size_t a = 0u; a < n; ++a) {
173234
auto const row_a = offset + a;
@@ -176,9 +237,10 @@ link_cell_kokkos(std::span<Cell *const> cells, BoxGeometry const &box_geo,
176237
auto const ii = id_to_index(id_column[row_a]);
177238
if (ii >= 0) {
178239
// Hoist p1's position out of the inner loops (see intra_kernel).
179-
auto const p1_pos = Utils::Vector3d{position_column[3u * row_a + 0u],
180-
position_column[3u * row_a + 1u],
181-
position_column[3u * row_a + 2u]};
240+
auto const *const p1_base = position_column + row_a * pos_row_stride;
241+
auto const p1_pos =
242+
Utils::Vector3d{p1_base[0u], p1_base[pos_comp_stride],
243+
p1_base[2u * pos_comp_stride]};
182244
// pairs with neighboring cells, same order as before
183245
for (auto *neighbor : cells[i]->neighbors().red()) {
184246
auto &nb_store = neighbor->store();
@@ -188,10 +250,11 @@ link_cell_kokkos(std::span<Cell *const> cells, BoxGeometry const &box_geo,
188250
auto const row_k = nb_offset + k;
189251
if (id_column[row_k] <= max_id) {
190252
p2.attach_to_store(nb_store, static_cast<int>(row_k));
253+
auto const *const p2_base =
254+
position_column + row_k * pos_row_stride;
191255
auto const p2_pos =
192-
Utils::Vector3d{position_column[3u * row_k + 0u],
193-
position_column[3u * row_k + 1u],
194-
position_column[3u * row_k + 2u]};
256+
Utils::Vector3d{p2_base[0u], p2_base[pos_comp_stride],
257+
p2_base[2u * pos_comp_stride]};
195258
if (verlet_criterion(p1, p2,
196259
box_geo.get_mi_dist2(p1_pos, p2_pos))) {
197260
auto const jj = id_to_index(id_column[row_k]);

0 commit comments

Comments
 (0)