peclet-dem 1.1.0
Performance-portable XPBD Discrete Element Method (Kokkos + ArborX)
Loading...
Searching...
No Matches
mpi_halo.hpp
Go to the documentation of this file.
1
29#ifndef PECLET_DEM_MPI_HALO_HPP
30#define PECLET_DEM_MPI_HALO_HPP
31#ifdef PECLET_DEM_MPI
32
33#include <mpi.h>
34
35#include <algorithm>
36#include <array>
37#include <cmath>
38#include <cstddef>
39#include <cstring>
40#include <Kokkos_Core.hpp>
41#include <limits>
42#include <stdexcept>
43#include <string>
44#include <type_traits>
45#include <unordered_map>
46#include <vector>
47
48#include "dem_portable.hpp" // F3, F4
49#include "particles.hpp" // Particles, V3/V4/Vf/Vi, CpExec/CpMem
50#include "peclet/core/common/types.hpp"
51#include "peclet/core/common/view.hpp"
52#include "peclet/core/decomp/block_decomposer.hpp"
53#include "peclet/core/halo/particle_halo.hpp"
54#include "peclet/core/halo/particle_halo_topology.hpp"
55#include "peclet/core/halo/particle_migrator.hpp"
56#include "peclet/core/halo/particle_rebalance.hpp"
57
58namespace peclet::dem {
59
60// All non-position per-particle state in one record, so the substep gather is a single MPI exchange
61// (latency dominates the host-staged path). Mirrors MpiParticleHalo::GatherPack; positions are
62// forwarded separately (they need the periodic image shift). invMass is its own field here (the
63// CUDA SoA carries it in pos.w; the Kokkos SoA keeps it in a separate array). POD =>
64// MPI_BYTE-copyable.
65struct MpiGatherPack {
66 F3 vel, velPred, angVel, angVelPred, invInertia;
67 F4 quat, quatPred;
68 float scale, invMass;
69 int shape;
70 // Modern-solver state a ghost must mirror from its owner: the global id (persistent-pair keys
71 // are gid-based — local slots are not stable identities across ranks / halo rebuilds), the
72 // material id (the narrowphase reads matId by RAW slot index), and the owner's warm grounded
73 // level (Guendelman support levels; the rank-local propagation sweeps continue from it, so a
74 // support chain crossing a rank boundary stays grounded).
75 int gid;
76 unsigned char material, grounded;
77 // Poisson-restitution orphan account (owner-authoritative; a ghost copy's rank-local credits and
78 // drawdowns are delivered to the owner by the velocity reverse, then the next mirror).
79 float orphan, orphanVPeak;
80};
81
82// One persistent-contact ledger entry carried through an ownership migration: the gid-based pair
83// key plus the previous substep's converged normal / tangential impulses and position-channel
84// load (the warm start + Coulomb-bound carry). POD => MPI_BYTE-copyable.
85struct WarmPairEntry {
86 unsigned long long key;
87 float lambda;
88 float lambdaT[3];
89 float posImpulse;
90 float restBank; // event-level (Poisson) restitution: remaining owed separation impulse
91 float restVPeak; // ... and the event's peak approach speed (> 0 = event active)
92};
93// Per-particle cap on carried pairs (sphere kissing number 12 + wall; lowest-weight entries are
94// dropped beyond it — a dropped entry only costs the receiving rank a cold warm-start there).
95inline constexpr int kWarmCarryMax = 14;
96
97// One force-engine (Hertz–Mindlin) per-pair history entry carried through an ownership migration:
98// the gid-based pair key + the Mindlin shear spring xi. The lagged patch stiffness (snPair) is NOT
99// carried — it resets at every pair-list rebuild anyway, and a migration forces one.
100struct HertzPairEntry {
101 unsigned long long key;
102 float xi[3];
103};
104
105// The committed per-particle state that defines a particle across steps — everything except its
106// position (which drives ownership and travels as the migrator's coordinate) and the predicted /
107// delta / ghost scratch the step rebuilds. This is the payload moved when a particle changes owner
108// during a load re-balance. POD => MPI_BYTE-copyable. Carries the particle's slice of the
109// persistent-contact ledger (each pair rides on BOTH locally owned endpoints; the unpack dedupes
110// by key) plus its grounded level, so a rebalance does not cold-restart the statics force
111// network. After the move the pair's owner under the new topology (ContactOwnership) uses its
112// copy; a non-owner's copy is gathered but never applied or committed, and vanishes after one
113// substep.
114struct MigratePack {
115 F4 quat;
116 F3 vel, angVel, invInertia;
117 float invMass, scale, targetScale;
118 int shapeId;
119 float planeFric0, planeFric1;
120 int gid;
121 int srcRank; // the sending rank: arrivals are re-ordered by it (see the file note)
122 unsigned char materialId, groundedLevel, numWarm, numHertz;
123 float orphan, orphanVPeak; // Poisson orphan account rides with its body across ownership
124 // External force / torque (a coupled run sets them before step_mpi; a drift migration inside
125 // the call must carry them -- docs/contact_solve_framework.md §5.2).
126 F3 extForce, extTorque;
127 WarmPairEntry warm[kWarmCarryMax];
128 // Force-engine (Hertz–Mindlin) history: the particle's slice of the cached pair list's Mindlin
129 // springs plus its per-(particle, wall) shear history + lagged wall patch stiffness.
130 HertzPairEntry hertz[kWarmCarryMax];
131 float hertzXiWall[Particles::kHertzMaxWalls][3];
132 float hertzSnWall[Particles::kHertzMaxWalls];
133};
134
135// Integer periodic image triple, each component in {-1, 0, 1}, packed as sum (c + 1) 4^d; and its
136// negation (the image the partner's owner must hold this body at).
137KOKKOS_INLINE_FUNCTION int imagePack(int x, int y, int z) {
138 return (x + 1) + 4 * (y + 1) + 16 * (z + 1);
139}
140KOKKOS_INLINE_FUNCTION int imageNegate(int p) {
141 const int x = p % 4 - 1, y = (p / 4) % 4 - 1, z = (p / 16) % 4 - 1;
142 return imagePack(-x, -y, -z);
143}
144
145// Which rank solves a contact (docs/mpi_momentum_conservation.md §2.1): EXACTLY ONE of the ranks
146// that see it. On this rank, slots [0, numReal) are owned and the rest are ghosts; for a ghost
147// slot g, ghostSource(g - numReal) is the rank it came from (this rank for a periodic
148// self-ghost); for an owned slot o, copyRanks[copyOffsets(o), copyOffsets(o+1)) are the ranks o
149// is ghosted to (cross-rank only). A contact between an owned o and a ghost g is owned here when
150// the ghost's owner cannot see the pair (it does not hold o as a ghost), else by the owner of the
151// lower-gid body; a body against its own periodic image is never owned. A wall contact belongs to
152// its body's owner. Built by ParticleHalo::contactOwnership over the current topology.
153struct ContactOwnership {
154 int rank;
155 int numReal;
156 Kokkos::View<const int*, CpMem> gid, ghostSource, copyOffsets, copyRanks;
157 // Image-aware (WO-9, §5.3): ghostImage(g - numReal) is the integer periodic image of ghost slot
158 // g, copyImage(k) that of send entry k (packed per axis as image + 1 in base 4). With every image
159 // sent, "the partner's owner sees the pair" means it holds THIS owned body at the opposite image.
160 Kokkos::View<const int*, CpMem> ghostImage, copyImage;
161 // Periodic self-images (a ghost of this rank's own body): selfImage[selfOffsets(o),
162 // selfOffsets(o+1)) are the images at which owned o is held as a self-ghost here.
163 Kokkos::View<const int*, CpMem> selfOffsets, selfImage;
164 KOKKOS_INLINE_FUNCTION bool operator()(const ContactC& c) const {
165 const int a = c.bodyA, b = c.bodyB;
166 if (b < 0)
167 return a < numReal; // wall contact: its body's owner
168 const bool ao = a < numReal, bo = b < numReal;
169 if (ao && bo)
170 return true; // both owned here
171 if (!ao && !bo)
172 return false; // cannot occur (queries come from owned bodies); defensive
173#if defined(PECLET_DEM_TEST_MUTANT) && PECLET_DEM_TEST_MUTANT == 1
174 return false; // G13 mutant 1 (docs/contact_solve_framework.md §9): no cross-rank pair is
175 // solved
176#endif
177 const int o = ao ? a : b, g = ao ? b : a;
178 if (gid(o) == gid(g))
179 return false; // a body against its own periodic image
180 const int s = ghostSource(g - numReal);
181 if (s == rank) {
182 // A self-image contact: its twin (the partner owned here, this body as a self-ghost at the
183 // opposite image) exists only if this rank holds o at that image. A body owned from across
184 // a periodic face (drift slack, §5.1) can have one orientation only -- then this one owns
185 // the pair, as in the cross-rank rule below (§12 S23).
186 const int want = imageNegate(ghostImage(g - numReal));
187 bool twin = false;
188 for (int k = selfOffsets(o); k < selfOffsets(o + 1); ++k)
189 if (selfImage(k) == want) {
190 twin = true;
191 break;
192 }
193 if (!twin)
194 return true;
195 } else {
196 bool partnerSees = false;
197 const int want = imageNegate(ghostImage(g - numReal));
198 for (int k = copyOffsets(o); k < copyOffsets(o + 1); ++k)
199 if (copyRanks(k) == s && copyImage(k) == want) {
200 partnerSees = true;
201 break;
202 }
203 if (!partnerSees)
204 return true; // the partner's owner cannot see this pair
205 }
206 return gid(o) < gid(g); // both owners see it (or a self-image twin): the lower gid's owner
207 }
208};
209
210// Ghost -> owner reverse payloads (docs/mpi_momentum_conservation.md §2.3). A ghost's accumulated
211// change since its baseline -- exactly what this rank's owned contacts wrote into it -- is summed
212// onto its owner by core's ParticleHalo<3>::reverse, which accumulates through
213// Kokkos::atomic_add: that applies the operator+ below. So operator+ SUMS the velocity, angular
214// velocity and orphan-account increments and takes the MAX of orphanPeak (an event peak speed,
215// not an additive quantity). POD => MPI_BYTE-copyable.
216struct VelocityIncrement {
217 float v[3];
218 float w[3];
219 float orphan;
220 float orphanPeak;
221};
222KOKKOS_INLINE_FUNCTION VelocityIncrement operator+(const VelocityIncrement& a,
223 const VelocityIncrement& b) {
224 VelocityIncrement r;
225 for (int d = 0; d < 3; ++d) {
226 r.v[d] = a.v[d] + b.v[d];
227 r.w[d] = a.w[d] + b.w[d];
228 }
229 r.orphan = a.orphan + b.orphan;
230 r.orphanPeak = a.orphanPeak > b.orphanPeak ? a.orphanPeak : b.orphanPeak;
231 return r;
232}
233// Position-phase payload: the predicted-position increment (the projection is translation-only).
234// Summed by core's reverse (Kokkos::atomic_add applies this operator+).
235struct PositionIncrement {
236 float x[3];
237};
238KOKKOS_INLINE_FUNCTION PositionIncrement operator+(const PositionIncrement& a,
239 const PositionIncrement& b) {
240 PositionIncrement r;
241 for (int d = 0; d < 3; ++d)
242 r.x[d] = a.x[d] + b.x[d];
243 return r;
244}
245
246// --- free-function pack/unpack kernels (namespace scope: nvcc forbids KOKKOS_LAMBDA in member fns)
247// ---
248
249inline void haloPackF3(V3 field, peclet::core::View<F3> owned, int n) {
250 Kokkos::parallel_for(
251 "peclet::dem::halo::packF3", Kokkos::RangePolicy<CpExec>(0, n),
252 KOKKOS_LAMBDA(int i) { owned(i) = F3{field(i, 0), field(i, 1), field(i, 2)}; });
253}
254inline void haloPackF4(V4 field, peclet::core::View<F4> owned, int n) {
255 Kokkos::parallel_for(
256 "peclet::dem::halo::packF4", Kokkos::RangePolicy<CpExec>(0, n),
257 KOKKOS_LAMBDA(int i) { owned(i) = F4{field(i, 0), field(i, 1), field(i, 2), field(i, 3)}; });
258}
259// ghost[g] -> field(no+slot(g),:), optionally adding the per-ghost periodic image shift (positions
260// only). `slot` maps the topology's ghost index (message-arrival block order) to the canonical
261// slot (ascending source rank); shift stays indexed by the topology's g.
262inline void haloUnpackF3(V3 field, peclet::core::View<F3> ghost, peclet::core::View<F3> shift,
263 Vi slot, int no, int ng, bool doShift) {
264 Kokkos::parallel_for(
265 "peclet::dem::halo::unpackF3", Kokkos::RangePolicy<CpExec>(0, ng), KOKKOS_LAMBDA(int g) {
266 F3 v = ghost(g);
267 if (doShift) {
268 v.x += shift(g).x;
269 v.y += shift(g).y;
270 v.z += shift(g).z;
271 }
272 const int s = no + slot(g);
273 field(s, 0) = v.x;
274 field(s, 1) = v.y;
275 field(s, 2) = v.z;
276 });
277}
278inline void haloUnpackF4(V4 field, peclet::core::View<F4> ghost, Vi slot, int no, int ng) {
279 Kokkos::parallel_for(
280 "peclet::dem::halo::unpackF4", Kokkos::RangePolicy<CpExec>(0, ng), KOKKOS_LAMBDA(int g) {
281 const int s = no + slot(g);
282 field(s, 0) = ghost(g).x;
283 field(s, 1) = ghost(g).y;
284 field(s, 2) = ghost(g).z;
285 field(s, 3) = ghost(g).w;
286 });
287}
288
289inline void haloPackGather(V3 vel, V3 velPred, V3 angVel, V3 angVelPred, V3 invInertia, V4 quat,
290 V4 quatPred, Vf scale, Vf invMass, Vi shapeId, Vi gid,
291 Kokkos::View<unsigned char*, CpMem> materialId,
292 Kokkos::View<unsigned char*, CpMem> grounded, Vf orphan, Vf orphanVPeak,
293 peclet::core::View<MpiGatherPack> owned, int n) {
294 Kokkos::parallel_for(
295 "peclet::dem::halo::packGather", Kokkos::RangePolicy<CpExec>(0, n), KOKKOS_LAMBDA(int i) {
296 MpiGatherPack g;
297 g.vel = F3{vel(i, 0), vel(i, 1), vel(i, 2)};
298 g.velPred = F3{velPred(i, 0), velPred(i, 1), velPred(i, 2)};
299 g.angVel = F3{angVel(i, 0), angVel(i, 1), angVel(i, 2)};
300 g.angVelPred = F3{angVelPred(i, 0), angVelPred(i, 1), angVelPred(i, 2)};
301 g.invInertia = F3{invInertia(i, 0), invInertia(i, 1), invInertia(i, 2)};
302 g.quat = F4{quat(i, 0), quat(i, 1), quat(i, 2), quat(i, 3)};
303 g.quatPred = F4{quatPred(i, 0), quatPred(i, 1), quatPred(i, 2), quatPred(i, 3)};
304 g.scale = scale(i);
305 g.invMass = invMass(i);
306 g.shape = shapeId(i);
307 g.gid = gid(i);
308 g.material = materialId(i);
309 g.grounded = grounded(i);
310 g.orphan = orphan(i);
311 g.orphanVPeak = orphanVPeak(i);
312 owned(i) = g;
313 });
314}
315// Unpack the gathered owner state into the ghost slots [no, no+ng) (ghost g -> slot no+slot(g), as
316// in haloUnpackF3) and self-map realIndices (the owner is remote: velocity/position changes
317// landing on the ghost slot are delivered to it by the next reconciliation's reverse).
318inline void haloUnpackGather(V3 vel, V3 velPred, V3 angVel, V3 angVelPred, V3 invInertia, V4 quat,
319 V4 quatPred, Vf scale, Vf invMass, Vi shapeId, Vi realIndices, Vi gid,
320 Kokkos::View<unsigned char*, CpMem> materialId,
321 Kokkos::View<unsigned char*, CpMem> grounded, Vf orphan,
322 Vf orphanVPeak, peclet::core::View<MpiGatherPack> ghost, Vi slot,
323 int no, int ng) {
324 Kokkos::parallel_for(
325 "peclet::dem::halo::unpackGather", Kokkos::RangePolicy<CpExec>(0, ng), KOKKOS_LAMBDA(int g) {
326 const MpiGatherPack p = ghost(g);
327 const int s = no + slot(g);
328 vel(s, 0) = p.vel.x;
329 vel(s, 1) = p.vel.y;
330 vel(s, 2) = p.vel.z;
331 velPred(s, 0) = p.velPred.x;
332 velPred(s, 1) = p.velPred.y;
333 velPred(s, 2) = p.velPred.z;
334 angVel(s, 0) = p.angVel.x;
335 angVel(s, 1) = p.angVel.y;
336 angVel(s, 2) = p.angVel.z;
337 angVelPred(s, 0) = p.angVelPred.x;
338 angVelPred(s, 1) = p.angVelPred.y;
339 angVelPred(s, 2) = p.angVelPred.z;
340 invInertia(s, 0) = p.invInertia.x;
341 invInertia(s, 1) = p.invInertia.y;
342 invInertia(s, 2) = p.invInertia.z;
343 quat(s, 0) = p.quat.x;
344 quat(s, 1) = p.quat.y;
345 quat(s, 2) = p.quat.z;
346 quat(s, 3) = p.quat.w;
347 quatPred(s, 0) = p.quatPred.x;
348 quatPred(s, 1) = p.quatPred.y;
349 quatPred(s, 2) = p.quatPred.z;
350 quatPred(s, 3) = p.quatPred.w;
351 scale(s) = p.scale;
352 invMass(s) = p.invMass;
353 shapeId(s) = p.shape;
354 realIndices(s) = s;
355 gid(s) = p.gid;
356 materialId(s) = p.material;
357 grounded(s) = p.grounded;
358 orphan(s) = p.orphan;
359 orphanVPeak(s) = p.orphanVPeak;
360 });
361}
362
363// --- ghost -> owner reconciliation kernels (docs/mpi_momentum_conservation.md §2.3). In every
364// ghost kernel g in [0, ng) is the TOPOLOGY ghost index (the order core's forward/reverse use) and
365// s = no + slot(g) its canonical SoA slot. ---
366
367// Velocity baselines: base*(g) = the ghost slot's current value.
368inline void haloMarkVelocityBaseline(V3 velPred, V3 angVelPred, Vf orphan, Vf orphanPeak, V3 baseV,
369 V3 baseW, Vf baseO, Vf basePk, Vi slot, int no, int ng) {
370 Kokkos::parallel_for(
371 "peclet::dem::halo::markVelBase", Kokkos::RangePolicy<CpExec>(0, ng), KOKKOS_LAMBDA(int g) {
372 const int s = no + slot(g);
373 for (int d = 0; d < 3; ++d) {
374 baseV(g, d) = velPred(s, d);
375 baseW(g, d) = angVelPred(s, d);
376 }
377 baseO(g) = orphan(s);
378 basePk(g) = orphanPeak(s);
379 });
380}
381// inc(g) = ghost slot - baseline, component-wise; the peak travels only if it rose above its
382// baseline (else 0), so a decayed owner peak is never undone.
383inline void haloPackVelocityIncrement(V3 velPred, V3 angVelPred, Vf orphan, Vf orphanPeak, V3 baseV,
384 V3 baseW, Vf baseO, Vf basePk,
385 peclet::core::View<VelocityIncrement> inc, Vi slot, int no,
386 int ng) {
387 Kokkos::parallel_for(
388 "peclet::dem::halo::packVelInc", Kokkos::RangePolicy<CpExec>(0, ng), KOKKOS_LAMBDA(int g) {
389 const int s = no + slot(g);
390 VelocityIncrement r;
391 for (int d = 0; d < 3; ++d) {
392 r.v[d] = velPred(s, d) - baseV(g, d);
393 r.w[d] = angVelPred(s, d) - baseW(g, d);
394 }
395 r.orphan = orphan(s) - baseO(g);
396 r.orphanPeak = orphanPeak(s) > basePk(g) ? orphanPeak(s) : 0.0f;
397 inc(g) = r;
398 });
399}
400// Owned rows [0, no) take the summed increments of all their ghost copies.
401inline void haloApplyVelocityIncrement(V3 velPred, V3 angVelPred, Vf orphan, Vf orphanPeak,
402 peclet::core::View<VelocityIncrement> ownedInc, int no) {
403 Kokkos::parallel_for(
404 "peclet::dem::halo::applyVelInc", Kokkos::RangePolicy<CpExec>(0, no), KOKKOS_LAMBDA(int i) {
405 const VelocityIncrement r = ownedInc(i);
406 for (int d = 0; d < 3; ++d) {
407 velPred(i, d) += r.v[d];
408 angVelPred(i, d) += r.w[d];
409 }
410 orphan(i) = Kokkos::fmax(0.0f, orphan(i) + r.orphan);
411 orphanPeak(i) = Kokkos::fmax(orphanPeak(i), r.orphanPeak);
412 });
413}
414inline void haloMarkPositionBaseline(V3 posPred, V3 baseX, Vi slot, int no, int ng) {
415 Kokkos::parallel_for(
416 "peclet::dem::halo::markPosBase", Kokkos::RangePolicy<CpExec>(0, ng), KOKKOS_LAMBDA(int g) {
417 const int s = no + slot(g);
418 for (int d = 0; d < 3; ++d)
419 baseX(g, d) = posPred(s, d);
420 });
421}
422// A position increment is a difference of two values in one image frame, so it needs no periodic
423// shift on its way to the owner.
424inline void haloPackPositionIncrement(V3 posPred, V3 baseX,
425 peclet::core::View<PositionIncrement> inc, Vi slot, int no,
426 int ng) {
427 Kokkos::parallel_for(
428 "peclet::dem::halo::packPosInc", Kokkos::RangePolicy<CpExec>(0, ng), KOKKOS_LAMBDA(int g) {
429 const int s = no + slot(g);
430 PositionIncrement r;
431 for (int d = 0; d < 3; ++d)
432 r.x[d] = posPred(s, d) - baseX(g, d);
433 inc(g) = r;
434 });
435}
436inline void haloApplyPositionIncrement(V3 posPred, peclet::core::View<PositionIncrement> ownedInc,
437 int no) {
438 Kokkos::parallel_for(
439 "peclet::dem::halo::applyPosInc", Kokkos::RangePolicy<CpExec>(0, no), KOKKOS_LAMBDA(int i) {
440 const PositionIncrement r = ownedInc(i);
441 for (int d = 0; d < 3; ++d)
442 posPred(i, d) += r.x[d];
443 });
444}
445// The legacy-friction contact count, column 1 of planeFriction: ghost -> owner sum, then back.
446inline void haloPackGhostColumn(Kokkos::View<float* [2], CpMem> pf,
447 peclet::core::View<float> ghostVal, Vi slot, int no, int ng) {
448 Kokkos::parallel_for(
449 "peclet::dem::halo::packGhostCol", Kokkos::RangePolicy<CpExec>(0, ng),
450 KOKKOS_LAMBDA(int g) { ghostVal(g) = pf(no + slot(g), 1); });
451}
452inline void haloAddOwnedColumn(Kokkos::View<float* [2], CpMem> pf,
453 peclet::core::View<float> ownedVal, int no) {
454 Kokkos::parallel_for(
455 "peclet::dem::halo::addOwnedCol", Kokkos::RangePolicy<CpExec>(0, no),
456 KOKKOS_LAMBDA(int i) { pf(i, 1) += ownedVal(i); });
457}
458inline void haloPackOwnedColumn(Kokkos::View<float* [2], CpMem> pf,
459 peclet::core::View<float> ownedVal, int no) {
460 Kokkos::parallel_for(
461 "peclet::dem::halo::packOwnedCol", Kokkos::RangePolicy<CpExec>(0, no),
462 KOKKOS_LAMBDA(int i) { ownedVal(i) = pf(i, 1); });
463}
464inline void haloUnpackGhostColumn(Kokkos::View<float* [2], CpMem> pf,
465 peclet::core::View<float> ghostVal, Vi slot, int no, int ng) {
466 Kokkos::parallel_for(
467 "peclet::dem::halo::unpackGhostCol", Kokkos::RangePolicy<CpExec>(0, ng),
468 KOKKOS_LAMBDA(int g) { pf(no + slot(g), 1) = ghostVal(g); });
469}
470// The Jacobi count-averaging counts (constraintCounts: per body slot, the contacts this rank
471// solved at it): ghost -> owner sum, then back. Integers, so the sum is exact in any order.
472inline void haloPackGhostCount(Vi counts, peclet::core::View<int> ghostCnt, Vi slot, int no,
473 int ng) {
474 Kokkos::parallel_for(
475 "peclet::dem::halo::packGhostCount", Kokkos::RangePolicy<CpExec>(0, ng),
476 KOKKOS_LAMBDA(int g) { ghostCnt(g) = counts(no + slot(g)); });
477}
478inline void haloAddPackOwnedCount(Vi counts, peclet::core::View<int> ownedCnt, int no) {
479 Kokkos::parallel_for(
480 "peclet::dem::halo::addPackOwnedCount", Kokkos::RangePolicy<CpExec>(0, no),
481 KOKKOS_LAMBDA(int i) {
482 counts(i) += ownedCnt(i);
483 ownedCnt(i) = counts(i);
484 });
485}
486inline void haloUnpackGhostCount(Vi counts, peclet::core::View<int> ghostCnt, Vi slot, int no,
487 int ng) {
488 Kokkos::parallel_for(
489 "peclet::dem::halo::unpackGhostCount", Kokkos::RangePolicy<CpExec>(0, ng),
490 KOKKOS_LAMBDA(int g) { counts(no + slot(g)) = ghostCnt(g); });
491}
492
493// --- fused owner -> ghost forwards (WO-5): one exchange instead of two when rotation is forwarded.
494// Pure copies (plus the same float shift add as haloUnpackF3), so bit-identical to the separate
495// forwards. POD => MPI_BYTE-copyable. ---
496struct VelocityState {
497 float v[3];
498 float w[3];
499};
500struct PositionState {
501 float x[3];
502 float q[4];
503};
504// Owner-side packs for a forward take an optional row list (`rows`, n = its length): the forward
505// reads the owned array only at the send and self-image rows (the interface), so packing those
506// rows alone is the same exchange. Empty `rows`: rows [0, n).
507inline void haloPackVelocityState(V3 velPred, V3 angVelPred,
508 peclet::core::View<VelocityState> owned, int n, Vi rows = {}) {
509 const bool list = rows.extent(0) > 0;
510 Kokkos::parallel_for(
511 "peclet::dem::halo::packVelState", Kokkos::RangePolicy<CpExec>(0, n), KOKKOS_LAMBDA(int j) {
512 const int i = list ? rows(j) : j;
513 VelocityState r;
514 for (int d = 0; d < 3; ++d) {
515 r.v[d] = velPred(i, d);
516 r.w[d] = angVelPred(i, d);
517 }
518 owned(i) = r;
519 });
520}
521inline void haloUnpackVelocityState(V3 velPred, V3 angVelPred,
522 peclet::core::View<VelocityState> ghost, Vi slot, int no,
523 int ng) {
524 Kokkos::parallel_for(
525 "peclet::dem::halo::unpackVelState", Kokkos::RangePolicy<CpExec>(0, ng),
526 KOKKOS_LAMBDA(int g) {
527 const VelocityState r = ghost(g);
528 const int s = no + slot(g);
529 for (int d = 0; d < 3; ++d) {
530 velPred(s, d) = r.v[d];
531 angVelPred(s, d) = r.w[d];
532 }
533 });
534}
535inline void haloPackPositionState(V3 posPred, V4 quatPred, peclet::core::View<PositionState> owned,
536 int n, Vi rows = {}) {
537 const bool list = rows.extent(0) > 0;
538 Kokkos::parallel_for(
539 "peclet::dem::halo::packPosState", Kokkos::RangePolicy<CpExec>(0, n), KOKKOS_LAMBDA(int j) {
540 const int i = list ? rows(j) : j;
541 PositionState r;
542 for (int d = 0; d < 3; ++d)
543 r.x[d] = posPred(i, d);
544 for (int d = 0; d < 4; ++d)
545 r.q[d] = quatPred(i, d);
546 owned(i) = r;
547 });
548}
549// The periodic image shift is added to x exactly as haloUnpackF3 adds it (one float add per axis).
550inline void haloUnpackPositionState(V3 posPred, V4 quatPred,
551 peclet::core::View<PositionState> ghost,
552 peclet::core::View<F3> shift, Vi slot, int no, int ng) {
553 Kokkos::parallel_for(
554 "peclet::dem::halo::unpackPosState", Kokkos::RangePolicy<CpExec>(0, ng),
555 KOKKOS_LAMBDA(int g) {
556 const PositionState r = ghost(g);
557 const F3 sh = shift(g);
558 const int s = no + slot(g);
559 posPred(s, 0) = r.x[0] + sh.x;
560 posPred(s, 1) = r.x[1] + sh.y;
561 posPred(s, 2) = r.x[2] + sh.z;
562 for (int d = 0; d < 4; ++d)
563 quatPred(s, d) = r.q[d];
564 });
565}
566
567// --- rank-level M (docs/contact_solve_framework.md §13.3): the opening exchange, the a-weighted
568// pack, the k-weighted owner apply, the orphan shares and the velocity slot map. All payloads are
569// POD (MPI_BYTE-copyable); core's reverse accumulates through Kokkos::atomic_add, which applies
570// operator+ (sums floats and ints, max of the peak). In every ghost kernel g is the TOPOLOGY ghost
571// index and s = no + slot(g) its canonical SoA slot, as above. ---
572
573// PGS opening, reverse: the warm-start increments (raw: known impulses) plus the slot's local
574// active copy counts a_vel, a_pos.
575struct OpeningIncrement {
576 float v[3];
577 float w[3];
578 float orphan;
579 float orphanPeak;
580 int aVel;
581 int aPos;
582};
583KOKKOS_INLINE_FUNCTION OpeningIncrement operator+(const OpeningIncrement& a,
584 const OpeningIncrement& b) {
585 OpeningIncrement r;
586 for (int d = 0; d < 3; ++d) {
587 r.v[d] = a.v[d] + b.v[d];
588 r.w[d] = a.w[d] + b.w[d];
589 }
590 r.orphan = a.orphan + b.orphan;
591 r.orphanPeak = a.orphanPeak > b.orphanPeak ? a.orphanPeak : b.orphanPeak;
592 r.aVel = a.aVel + b.aVel;
593 r.aPos = a.aPos + b.aPos;
594 return r;
595}
596// PGS opening, forward: the owner's state, orphan balance B and peak, and the global counts k
597// (with rotation 40 B; without, w is dropped: 28 B).
598struct OpeningState {
599 float v[3];
600 float w[3];
601 float B;
602 float peak;
603 int kVel;
604 int kPos;
605};
606struct OpeningStateNoRot {
607 float v[3];
608 float B;
609 float peak;
610 int kVel;
611 int kPos;
612};
613// Later velocity forwards under Poisson: VelocityState plus the balance and peak (32 B; without
614// rotation 20 B).
615struct VelocityStateB {
616 float v[3];
617 float w[3];
618 float B;
619 float peak;
620};
621struct VelocityStateBNoRot {
622 float v[3];
623 float B;
624 float peak;
625};
626
627inline void haloPackOpeningIncrement(V3 velPred, V3 angVelPred, Vf orphan, Vf orphanPeak, V3 baseV,
628 V3 baseW, Vf baseO, Vf basePk, Vi aVel, Vi aPos,
629 peclet::core::View<OpeningIncrement> inc, Vi slot, int no,
630 int ng) {
631 Kokkos::parallel_for(
632 "peclet::dem::halo::packOpenInc", Kokkos::RangePolicy<CpExec>(0, ng), KOKKOS_LAMBDA(int g) {
633 const int s = no + slot(g);
634 OpeningIncrement r;
635 for (int d = 0; d < 3; ++d) {
636 r.v[d] = velPred(s, d) - baseV(g, d);
637 r.w[d] = angVelPred(s, d) - baseW(g, d);
638 }
639 r.orphan = orphan(s) - baseO(g);
640 r.orphanPeak = orphanPeak(s) > basePk(g) ? orphanPeak(s) : 0.0f;
641 r.aVel = aVel(s);
642 r.aPos = aPos(s);
643 inc(g) = r;
644 });
645}
646// Owned rows: the raw add (exactly haloApplyVelocityIncrement), then k = max(1, a(own) + sum a).
647inline void haloApplyOpening(V3 velPred, V3 angVelPred, Vf orphan, Vf orphanPeak, Vi aVel, Vi aPos,
648 Vi kVel, Vi kPos, peclet::core::View<OpeningIncrement> ownedInc,
649 int no) {
650 Kokkos::parallel_for(
651 "peclet::dem::halo::applyOpen", Kokkos::RangePolicy<CpExec>(0, no), KOKKOS_LAMBDA(int i) {
652 const OpeningIncrement r = ownedInc(i);
653 for (int d = 0; d < 3; ++d) {
654 velPred(i, d) += r.v[d];
655 angVelPred(i, d) += r.w[d];
656 }
657 orphan(i) = Kokkos::fmax(0.0f, orphan(i) + r.orphan);
658 orphanPeak(i) = Kokkos::fmax(orphanPeak(i), r.orphanPeak);
659 const int kv = aVel(i) + r.aVel, kp = aPos(i) + r.aPos;
660 kVel(i) = kv > 1 ? kv : 1;
661 kPos(i) = kp > 1 ? kp : 1;
662 });
663}
664template <class S>
665inline void haloPackOpeningState(V3 velPred, V3 angVelPred, Vf orphan, Vf orphanPeak, Vi kVel,
666 Vi kPos, peclet::core::View<S> owned, int no) {
667 Kokkos::parallel_for(
668 "peclet::dem::halo::packOpenState", Kokkos::RangePolicy<CpExec>(0, no), KOKKOS_LAMBDA(int i) {
669 const V3& wv = angVelPred; // captured outside the constexpr-if (nvcc)
670 S r;
671 for (int d = 0; d < 3; ++d)
672 r.v[d] = velPred(i, d);
673 if constexpr (std::is_same_v<S, OpeningState>) {
674 for (int d = 0; d < 3; ++d)
675 r.w[d] = wv(i, d);
676 }
677 r.B = orphan(i);
678 r.peak = orphanPeak(i);
679 r.kVel = kVel(i);
680 r.kPos = kPos(i);
681 owned(i) = r;
682 });
683}
684template <class S>
685inline void haloUnpackOpeningState(V3 velPred, V3 angVelPred, Vf orphan, Vf orphanPeak, Vi kVel,
686 Vi kPos, peclet::core::View<S> ghost, Vi slot, int no, int ng) {
687 Kokkos::parallel_for(
688 "peclet::dem::halo::unpackOpenState", Kokkos::RangePolicy<CpExec>(0, ng),
689 KOKKOS_LAMBDA(int g) {
690 const V3& wv = angVelPred; // captured outside the constexpr-if (nvcc)
691 const S r = ghost(g);
692 const int s = no + slot(g);
693 for (int d = 0; d < 3; ++d)
694 velPred(s, d) = r.v[d];
695 if constexpr (std::is_same_v<S, OpeningState>) {
696 for (int d = 0; d < 3; ++d)
697 wv(s, d) = r.w[d];
698 }
699 orphan(s) = r.B;
700 orphanPeak(s) = r.peak;
701 kVel(s) = r.kVel;
702 kPos(s) = r.kPos;
703 });
704}
705template <class S>
706inline void haloPackVelocityStateB(V3 velPred, V3 angVelPred, Vf orphan, Vf orphanPeak,
707 peclet::core::View<S> owned, int no, Vi rows = {}) {
708 const bool list = rows.extent(0) > 0;
709 Kokkos::parallel_for(
710 "peclet::dem::halo::packVelStateB", Kokkos::RangePolicy<CpExec>(0, no), KOKKOS_LAMBDA(int j) {
711 const int i = list ? rows(j) : j;
712 const V3& wv = angVelPred; // captured outside the constexpr-if (nvcc)
713 S r;
714 for (int d = 0; d < 3; ++d)
715 r.v[d] = velPred(i, d);
716 if constexpr (std::is_same_v<S, VelocityStateB>) {
717 for (int d = 0; d < 3; ++d)
718 r.w[d] = wv(i, d);
719 }
720 r.B = orphan(i);
721 r.peak = orphanPeak(i);
722 owned(i) = r;
723 });
724}
725template <class S>
726inline void haloUnpackVelocityStateB(V3 velPred, V3 angVelPred, Vf orphan, Vf orphanPeak,
727 peclet::core::View<S> ghost, Vi slot, int no, int ng) {
728 Kokkos::parallel_for(
729 "peclet::dem::halo::unpackVelStateB", Kokkos::RangePolicy<CpExec>(0, ng),
730 KOKKOS_LAMBDA(int g) {
731 const V3& wv = angVelPred; // captured outside the constexpr-if (nvcc)
732 const S r = ghost(g);
733 const int s = no + slot(g);
734 for (int d = 0; d < 3; ++d)
735 velPred(s, d) = r.v[d];
736 if constexpr (std::is_same_v<S, VelocityStateB>) {
737 for (int d = 0; d < 3; ++d)
738 wv(s, d) = r.w[d];
739 }
740 orphan(s) = r.B;
741 orphanPeak(s) = r.peak;
742 });
743}
744// Owner seeds (§4.6 step 4): the owned rows' values at the last reconciliation. The seeds, the M
745// applies and the orphan restore take the phase's M row list (ParticleHalo::buildMRows: the
746// interface rows plus every k > 1 row; n = its length) -- no other owned row reads a seed or
747// receives an increment. Empty `rows`: rows [0, n).
748inline void haloMarkOwnerVelocitySeeds(V3 velPred, V3 angVelPred, Vf orphan, V3 seedV, V3 seedW,
749 Vf seedO, int no, Vi rows = {}) {
750 const bool list = rows.extent(0) > 0;
751 Kokkos::parallel_for(
752 "peclet::dem::halo::markOwnerVelSeed", Kokkos::RangePolicy<CpExec>(0, no),
753 KOKKOS_LAMBDA(int j) {
754 const int i = list ? rows(j) : j;
755 for (int d = 0; d < 3; ++d) {
756 seedV(i, d) = velPred(i, d);
757 seedW(i, d) = angVelPred(i, d);
758 }
759 seedO(i) = orphan(i);
760 });
761}
762inline void haloMarkOwnerPositionSeeds(V3 posPred, V3 seedX, int no, Vi rows = {}) {
763 const bool list = rows.extent(0) > 0;
764 Kokkos::parallel_for(
765 "peclet::dem::halo::markOwnerPosSeed", Kokkos::RangePolicy<CpExec>(0, no),
766 KOKKOS_LAMBDA(int j) {
767 const int i = list ? rows(j) : j;
768 for (int d = 0; d < 3; ++d)
769 seedX(i, d) = posPred(i, d);
770 });
771}
772// Orphan shares (§13.3): every slot [0, n) of a body with k_vel > 1 -- owned row and ghosts --
773// holds B / k of the balance B it holds now (the owner's, just forwarded); k = 1 keeps B.
774inline void haloSetOrphanShares(Vf orphan, Vi kVel, int n) {
775 Kokkos::parallel_for(
776 "peclet::dem::halo::orphanShares", Kokkos::RangePolicy<CpExec>(0, n), KOKKOS_LAMBDA(int q) {
777 const int k = kVel(q);
778 if (k > 1)
779 orphan(q) = orphan(q) / static_cast<float>(k);
780 });
781}
782// The M rows of a phase, ascending: owned rows on the interface (`mark`) or with k > 1 (see
783// ParticleHalo::buildMRows). Returns their count.
784inline int haloBuildMRows(Vi k, Kokkos::View<const unsigned char*, CpMem> mark, Vi rows, int no) {
785 int cnt = 0;
786 Kokkos::parallel_scan(
787 "peclet::dem::halo::mRows", Kokkos::RangePolicy<CpExec>(0, no),
788 KOKKOS_LAMBDA(int i, int& run, const bool final) {
789 if (mark(i) != 0 || k(i) > 1) {
790 if (final)
791 rows(run) = i;
792 ++run;
793 }
794 },
795 cnt);
796 return cnt;
797}
798// ---- the fused rank-level M reconciliation (docs/contact_solve_framework.md §13.3) ----
799// One reconciliation is three dem kernels around core's reverse and forward:
800// 1. MPackZero: every ghost's a-weighted increment, and the phase's M rows zeroed (one launch);
801// 2. MApply: per M row the k-weighted owner apply, then -- for an interface row -- the forward
802// payload, the owner seeds and (Poisson) the owner's orphan share;
803// 3. MUnpack: per ghost the forwarded payload, its consensus correction (§12 S14), its orphan
804// share and its new baseline.
805// Bit for bit the unfused sequence pack / zero / reverse / apply / pack / forward / unpack /
806// consensus / seeds / shares / baselines: every owned row and every ghost slot sees the same
807// operations in the same order (the forward reads only the payload rows, which nothing writes
808// after MApply; the owner seeds and the pre-share payload take the post-apply values the forward
809// leaves untouched). The payload type S picks the forwarded fields: VelocityStateB (v, w, B, peak),
810// VelocityStateBNoRot (v, B, peak), VelocityState (v, w) or F3 (v alone); PositionState (x, q) or
811// F3 (x alone). The periodic image shift is added to a ghost position exactly as haloUnpackF3 does.
812KOKKOS_INLINE_FUNCTION float payloadLin(const F3& r, int d) {
813 return d == 0 ? r.x : (d == 1 ? r.y : r.z);
814}
815KOKKOS_INLINE_FUNCTION void setPayloadLin(F3& r, int d, float x) {
816 (d == 0 ? r.x : (d == 1 ? r.y : r.z)) = x;
817}
818template <class S>
819KOKKOS_INLINE_FUNCTION float payloadLin(const S& r, int d) {
820 if constexpr (std::is_same_v<S, PositionState>)
821 return r.x[d];
822 else
823 return r.v[d];
824}
825template <class S>
826KOKKOS_INLINE_FUNCTION void setPayloadLin(S& r, int d, float x) {
827 if constexpr (std::is_same_v<S, PositionState>)
828 r.x[d] = x;
829 else
830 r.v[d] = x;
831}
832template <class S>
833inline constexpr bool kPayloadW =
834 std::is_same_v<S, VelocityState> || std::is_same_v<S, VelocityStateB>;
835template <class S>
836inline constexpr bool kPayloadB =
837 std::is_same_v<S, VelocityStateB> || std::is_same_v<S, VelocityStateBNoRot>;
838
839// 1 (velocity). inc = a(s) (x - baseline) for v, w and the orphan account; the peak travels only
840// from an active slot that raised it. A slot with a = 0 is never written by the phase; a debug
841// build checks that its increment is zero (a non-zero one would be dropped).
842inline void haloVelocityMPackZero(V3 velPred, V3 angVelPred, Vf orphan, Vf orphanPeak, V3 baseV,
843 V3 baseW, Vf baseO, Vf basePk, Vi aVel,
844 peclet::core::View<VelocityIncrement> inc, Vi slot, int no,
845 int ng, peclet::core::View<VelocityIncrement> ownedInc, Vi rows,
846 int nRows) {
847 Kokkos::parallel_for(
848 "peclet::dem::halo::velMPackZero", Kokkos::RangePolicy<CpExec>(0, ng + nRows),
849 KOKKOS_LAMBDA(int j) {
850 if (j >= ng) {
851 ownedInc(rows(j - ng)) = VelocityIncrement{};
852 return;
853 }
854 const int g = j;
855 const int s = no + slot(g);
856 const float f = static_cast<float>(aVel(s));
857 VelocityIncrement r;
858 for (int d = 0; d < 3; ++d) {
859 r.v[d] = f * (velPred(s, d) - baseV(g, d));
860 r.w[d] = f * (angVelPred(s, d) - baseW(g, d));
861 }
862 r.orphan = f * (orphan(s) - baseO(g));
863 r.orphanPeak = (f > 0.0f && orphanPeak(s) > basePk(g)) ? orphanPeak(s) : 0.0f;
864#ifndef NDEBUG
865 if (aVel(s) == 0) {
866 bool moved = orphan(s) != baseO(g);
867 for (int d = 0; d < 3; ++d)
868 moved = moved || velPred(s, d) != baseV(g, d) || angVelPred(s, d) != baseW(g, d);
869 if (moved)
870 Kokkos::abort(
871 "peclet::dem rank-level M: an inactive velocity slot (a = 0) was written");
872 }
873#endif
874 inc(g) = r;
875 });
876}
877// 2 (velocity). The apply (§13.3 table): k <= 1 is c771e07's raw add, bit for bit; k > 1 takes
878// x = seed + (a(own) (x - seed) + sum inc) / k, and the balance
879// B_new = max(0, B + a(own) (orphan - B / k) + sum inc) (the owner row holds the share B / k).
880// Clamp hits are counted (split_stats.orphanClamps; must stay 0), and a debug build asserts the
881// pre-clamp balance is >= -1e-6 max(B, 1e-30). Then the payload (interface rows), the seeds, and
882// under Poisson (`shares`) the owner's share B / k.
883template <class S>
884inline void haloVelocityMApply(V3 velPred, V3 angVelPred, Vf orphan, Vf orphanPeak, V3 seedV,
885 V3 seedW, Vf seedO, Vi aVel, Vi kVel,
886 peclet::core::View<VelocityIncrement> ownedInc,
887 Kokkos::View<int, CpMem> clamps,
888 Kokkos::View<float, CpMem> consensus, Vi rows, int nRows,
889 Kokkos::View<const unsigned char*, CpMem> iface,
890 peclet::core::View<S> out, bool shares) {
891 Kokkos::parallel_for(
892 "peclet::dem::halo::velMApply", Kokkos::RangePolicy<CpExec>(0, nRows), KOKKOS_LAMBDA(int j) {
893 const V3& wv = angVelPred; // captured outside the constexpr-if (nvcc)
894 const Vf& ov = orphan;
895 const Vf& pv = orphanPeak;
896 const int i = rows(j);
897 const VelocityIncrement r = ownedInc(i);
898 const int k = kVel(i);
899 float pre;
900 if (k <= 1) {
901 for (int d = 0; d < 3; ++d) {
902 velPred(i, d) += r.v[d];
903 angVelPred(i, d) += r.w[d];
904 }
905 pre = orphan(i) + r.orphan;
906 } else {
907 const float kf = static_cast<float>(k), af = static_cast<float>(aVel(i));
908 float c2 = 0.0f; // §12 S14: the owner copy's consensus correction (increment form)
909 for (int d = 0; d < 3; ++d) {
910#if defined(PECLET_DEM_TEST_MUTANT) && PECLET_DEM_TEST_MUTANT == 2
911 const float own = velPred(i, d) - seedV(i, d),
912 mean = af * own + r.v[d]; // G13 mutant 2
913#else
914 const float own = velPred(i, d) - seedV(i, d), mean = (af * own + r.v[d]) / kf;
915#endif
916 c2 += (mean - own) * (mean - own);
917 velPred(i, d) = seedV(i, d) + mean;
918 angVelPred(i, d) = seedW(i, d) + (af * (angVelPred(i, d) - seedW(i, d)) + r.w[d]) / kf;
919 }
920 if (af > 0.0f && c2 > 0.0f)
921 Kokkos::atomic_max(&consensus(), Kokkos::sqrt(c2));
922 const float B = seedO(i);
923 pre = B + af * (orphan(i) - B / kf) + r.orphan;
924#ifndef NDEBUG
925 if (!(pre >= -1e-6f * Kokkos::fmax(B, 1e-30f)))
926 Kokkos::abort("peclet::dem rank-level M: the orphan balance was overdrawn");
927#endif
928 }
929 if (pre < 0.0f)
930 Kokkos::atomic_add(&clamps(), 1);
931 orphan(i) = Kokkos::fmax(0.0f, pre);
932 orphanPeak(i) = Kokkos::fmax(orphanPeak(i), r.orphanPeak);
933 if (iface(i) != 0) {
934 S p;
935 for (int d = 0; d < 3; ++d)
936 setPayloadLin(p, d, velPred(i, d));
937 if constexpr (kPayloadW<S>) {
938 for (int d = 0; d < 3; ++d)
939 p.w[d] = wv(i, d);
940 }
941 if constexpr (kPayloadB<S>) {
942 p.B = ov(i);
943 p.peak = pv(i);
944 }
945 out(i) = p;
946 }
947 for (int d = 0; d < 3; ++d) {
948 seedV(i, d) = velPred(i, d);
949 seedW(i, d) = angVelPred(i, d);
950 }
951 seedO(i) = orphan(i);
952 if (shares && k > 1)
953 orphan(i) = orphan(i) / static_cast<float>(k);
954 });
955}
956// 3 (velocity). The ghost copies: the payload; §12 S14's consensus correction of every ACTIVE ghost
957// copy (a > 0) of a k > 1 body, |(x_new - baseline) - inc / a| (inc = the a-weighted increment it
958// packed, still in the send buffer), atomic-maxed into `consensus`; the share B / k; the baseline.
959template <class S>
960inline void haloVelocityMUnpack(V3 velPred, V3 angVelPred, Vf orphan, Vf orphanPeak,
961 peclet::core::View<S> ghost, V3 baseV, V3 baseW, Vf baseO,
962 Vf basePk, Vi aVel, Vi kVel,
963 peclet::core::View<VelocityIncrement> inc,
964 Kokkos::View<float, CpMem> consensus, Vi slot, int no, int ng,
965 bool shares) {
966 Kokkos::parallel_for(
967 "peclet::dem::halo::velMUnpack", Kokkos::RangePolicy<CpExec>(0, ng), KOKKOS_LAMBDA(int g) {
968 const V3& wv = angVelPred; // captured outside the constexpr-if (nvcc)
969 const Vf& ov = orphan;
970 const Vf& pv = orphanPeak;
971 const S p = ghost(g);
972 const int s = no + slot(g);
973 for (int d = 0; d < 3; ++d)
974 velPred(s, d) = payloadLin(p, d);
975 if constexpr (kPayloadW<S>) {
976 for (int d = 0; d < 3; ++d)
977 wv(s, d) = p.w[d];
978 }
979 if constexpr (kPayloadB<S>) {
980 ov(s) = p.B;
981 pv(s) = p.peak;
982 }
983 const int k = kVel(s);
984 if (aVel(s) > 0 && k > 1) {
985 const float af = static_cast<float>(aVel(s));
986 const VelocityIncrement r = inc(g);
987 float c2 = 0.0f;
988 for (int d = 0; d < 3; ++d) {
989 const float e = (velPred(s, d) - baseV(g, d)) - r.v[d] / af;
990 c2 += e * e;
991 }
992 if (c2 > 0.0f)
993 Kokkos::atomic_max(&consensus(), Kokkos::sqrt(c2));
994 }
995 if (shares && k > 1)
996 orphan(s) = orphan(s) / static_cast<float>(k);
997 for (int d = 0; d < 3; ++d) {
998 baseV(g, d) = velPred(s, d);
999 baseW(g, d) = angVelPred(s, d);
1000 }
1001 baseO(g) = orphan(s);
1002 basePk(g) = orphanPeak(s);
1003 });
1004}
1005// Phase end (§13.3): the owner row of a k > 1 body gets its balance back,
1006// B_seed + a(own) (orphan - B_seed / k); after the phase-final sync this is B_seed exactly.
1007inline void haloRestoreOrphanBalance(Vf orphan, Vf seedO, Vi aVel, Vi kVel, int no, Vi rows = {}) {
1008 const bool list = rows.extent(0) > 0;
1009 Kokkos::parallel_for(
1010 "peclet::dem::halo::restoreOrphan", Kokkos::RangePolicy<CpExec>(0, no), KOKKOS_LAMBDA(int j) {
1011 const int i = list ? rows(j) : j;
1012 const int k = kVel(i);
1013 if (k <= 1)
1014 return;
1015 const float kf = static_cast<float>(k), B = seedO(i);
1016 orphan(i) = B + static_cast<float>(aVel(i)) * (orphan(i) - B / kf);
1017 });
1018}
1019// 1 (position). inc = a(s) (x - baseline), and the phase's M rows zeroed.
1020inline void haloPositionMPackZero(V3 posPred, V3 baseX, Vi aPos,
1021 peclet::core::View<PositionIncrement> inc, Vi slot, int no,
1022 int ng, peclet::core::View<PositionIncrement> ownedInc, Vi rows,
1023 int nRows) {
1024 Kokkos::parallel_for(
1025 "peclet::dem::halo::posMPackZero", Kokkos::RangePolicy<CpExec>(0, ng + nRows),
1026 KOKKOS_LAMBDA(int j) {
1027 if (j >= ng) {
1028 ownedInc(rows(j - ng)) = PositionIncrement{};
1029 return;
1030 }
1031 const int g = j;
1032 const int s = no + slot(g);
1033 const float f = static_cast<float>(aPos(s));
1034 PositionIncrement r;
1035 for (int d = 0; d < 3; ++d)
1036 r.x[d] = f * (posPred(s, d) - baseX(g, d));
1037#ifndef NDEBUG
1038 if (aPos(s) == 0 && (posPred(s, 0) != baseX(g, 0) || posPred(s, 1) != baseX(g, 1) ||
1039 posPred(s, 2) != baseX(g, 2)))
1040 Kokkos::abort("peclet::dem rank-level M: an inactive position slot (a = 0) was written");
1041#endif
1042 inc(g) = r;
1043 });
1044}
1045// 2 (position). The apply (raw add for k <= 1), the payload of an interface row, the seed.
1046template <class S>
1047inline void haloPositionMApply(V3 posPred, V4 quatPred, V3 seedX, Vi aPos, Vi kPos,
1048 peclet::core::View<PositionIncrement> ownedInc,
1049 Kokkos::View<float, CpMem> consensus, Vi rows, int nRows,
1050 Kokkos::View<const unsigned char*, CpMem> iface,
1051 peclet::core::View<S> out) {
1052 Kokkos::parallel_for(
1053 "peclet::dem::halo::posMApply", Kokkos::RangePolicy<CpExec>(0, nRows), KOKKOS_LAMBDA(int j) {
1054 const V4& qv = quatPred; // captured outside the constexpr-if (nvcc)
1055 const int i = rows(j);
1056 const PositionIncrement r = ownedInc(i);
1057 const int k = kPos(i);
1058 if (k <= 1) {
1059 for (int d = 0; d < 3; ++d)
1060 posPred(i, d) += r.x[d];
1061 } else {
1062 const float kf = static_cast<float>(k), af = static_cast<float>(aPos(i));
1063 float c2 = 0.0f; // §12 S14: the owner copy's consensus correction (increment form)
1064 for (int d = 0; d < 3; ++d) {
1065 const float own = posPred(i, d) - seedX(i, d), mean = (af * own + r.x[d]) / kf;
1066 c2 += (mean - own) * (mean - own);
1067 posPred(i, d) = seedX(i, d) + mean;
1068 }
1069 if (af > 0.0f && c2 > 0.0f)
1070 Kokkos::atomic_max(&consensus(), Kokkos::sqrt(c2));
1071 }
1072 if (iface(i) != 0) {
1073 S p;
1074 for (int d = 0; d < 3; ++d)
1075 setPayloadLin(p, d, posPred(i, d));
1076 if constexpr (std::is_same_v<S, PositionState>) {
1077 for (int d = 0; d < 4; ++d)
1078 p.q[d] = qv(i, d);
1079 }
1080 out(i) = p;
1081 }
1082 for (int d = 0; d < 3; ++d)
1083 seedX(i, d) = posPred(i, d);
1084 });
1085}
1086// 3 (position). The ghost copies: the payload (+ the periodic image shift), the consensus
1087// correction, the baseline.
1088template <class S>
1089inline void haloPositionMUnpack(V3 posPred, V4 quatPred, peclet::core::View<S> ghost,
1090 peclet::core::View<F3> shift, V3 baseX, Vi aPos, Vi kPos,
1091 peclet::core::View<PositionIncrement> inc,
1092 Kokkos::View<float, CpMem> consensus, Vi slot, int no, int ng) {
1093 Kokkos::parallel_for(
1094 "peclet::dem::halo::posMUnpack", Kokkos::RangePolicy<CpExec>(0, ng), KOKKOS_LAMBDA(int g) {
1095 const V4& qv = quatPred; // captured outside the constexpr-if (nvcc)
1096 const S p = ghost(g);
1097 const F3 sh = shift(g);
1098 const int s = no + slot(g);
1099 posPred(s, 0) = payloadLin(p, 0) + sh.x;
1100 posPred(s, 1) = payloadLin(p, 1) + sh.y;
1101 posPred(s, 2) = payloadLin(p, 2) + sh.z;
1102 if constexpr (std::is_same_v<S, PositionState>) {
1103 for (int d = 0; d < 4; ++d)
1104 qv(s, d) = p.q[d];
1105 }
1106 if (aPos(s) > 0 && kPos(s) > 1) {
1107 const float af = static_cast<float>(aPos(s));
1108 const PositionIncrement r = inc(g);
1109 float c2 = 0.0f;
1110 for (int d = 0; d < 3; ++d) {
1111 const float e = (posPred(s, d) - baseX(g, d)) - r.x[d] / af;
1112 c2 += e * e;
1113 }
1114 if (c2 > 0.0f)
1115 Kokkos::atomic_max(&consensus(), Kokkos::sqrt(c2));
1116 }
1117 for (int d = 0; d < 3; ++d)
1118 baseX(g, d) = posPred(s, d);
1119 });
1120}
1121// The g = 0 path's counts-only opening (§13.3): reverse a_pos, owner k_pos, forward k_pos.
1122inline void haloPackGhostInt(Vi src, peclet::core::View<int> ghost, Vi slot, int no, int ng) {
1123 Kokkos::parallel_for(
1124 "peclet::dem::halo::packGhostInt", Kokkos::RangePolicy<CpExec>(0, ng),
1125 KOKKOS_LAMBDA(int g) { ghost(g) = src(no + slot(g)); });
1126}
1127inline void haloOwnerCountK(Vi a, Vi k, peclet::core::View<int> owned, int no) {
1128 Kokkos::parallel_for(
1129 "peclet::dem::halo::ownerCountK", Kokkos::RangePolicy<CpExec>(0, no), KOKKOS_LAMBDA(int i) {
1130 const int kk = a(i) + owned(i);
1131 k(i) = kk > 1 ? kk : 1;
1132 owned(i) = k(i);
1133 });
1134}
1135inline void haloUnpackGhostInt(Vi dst, peclet::core::View<int> ghost, Vi slot, int no, int ng) {
1136 Kokkos::parallel_for(
1137 "peclet::dem::halo::unpackGhostInt", Kokkos::RangePolicy<CpExec>(0, ng),
1138 KOKKOS_LAMBDA(int g) { dst(no + slot(g)) = ghost(g); });
1139}
1140// Rank-level X (docs/contact_solve_framework.md §1.4, §13.5 WO-6): the g = 0 opening carries, with
1141// a_pos, the velocity activity mask -- bit col(r) of every rank on which the body has an active
1142// velocity copy. Reverse: OR the masks, sum the counts; forward: the owner's OR and k_pos. 16 B per
1143// ghost each way, in the opening's single round.
1144struct PositionCountsMask {
1145 unsigned long long mask;
1146 int aPos;
1147 int pad;
1148};
1149KOKKOS_INLINE_FUNCTION PositionCountsMask operator+(const PositionCountsMask& a,
1150 const PositionCountsMask& b) {
1151 return PositionCountsMask{a.mask | b.mask, a.aPos + b.aPos, 0};
1152}
1153inline void haloPackGhostCountsMask(Vi aVel, Vi aPos, peclet::core::View<PositionCountsMask> ghost,
1154 Vi slot, unsigned long long bit, int no, int ng) {
1155 Kokkos::parallel_for(
1156 "peclet::dem::halo::packCountsMask", Kokkos::RangePolicy<CpExec>(0, ng),
1157 KOKKOS_LAMBDA(int g) {
1158 const int s = no + slot(g);
1159 ghost(g) = PositionCountsMask{aVel(s) > 0 ? bit : 0ull, aPos(s), 0};
1160 });
1161}
1162inline void haloOwnerCountsMask(Vi aVel, Vi aPos, Vi kPos,
1163 Kokkos::View<unsigned long long*, CpMem> velMask,
1164 peclet::core::View<PositionCountsMask> owned,
1165 unsigned long long bit, int no) {
1166 Kokkos::parallel_for(
1167 "peclet::dem::halo::ownerCountsMask", Kokkos::RangePolicy<CpExec>(0, no),
1168 KOKKOS_LAMBDA(int i) {
1169 const PositionCountsMask r = owned(i);
1170 const int kk = aPos(i) + r.aPos;
1171 kPos(i) = kk > 1 ? kk : 1;
1172 const unsigned long long m = r.mask | (aVel(i) > 0 ? bit : 0ull);
1173 velMask(i) = m;
1174 owned(i) = PositionCountsMask{m, kPos(i), 0};
1175 });
1176}
1177inline void haloUnpackCountsMask(Vi kPos, Kokkos::View<unsigned long long*, CpMem> velMask,
1178 peclet::core::View<PositionCountsMask> ghost, Vi slot, int no,
1179 int ng) {
1180 Kokkos::parallel_for(
1181 "peclet::dem::halo::unpackCountsMask", Kokkos::RangePolicy<CpExec>(0, ng),
1182 KOKKOS_LAMBDA(int g) {
1183 const int s = no + slot(g);
1184 kPos(s) = ghost(g).aPos;
1185 velMask(s) = ghost(g).mask;
1186 });
1187}
1188
1189// The velocity slot map (§6.2): ghost slot no + j takes the canonical slot canon(j) of its body on
1190// this rank (the owned slot for a self image, else the lowest ghost slot with its gid).
1191inline void haloMapVelocitySlots(Vi realIndices, Vi canon, int no, int ng) {
1192 Kokkos::parallel_for(
1193 "peclet::dem::halo::mapVelSlots", Kokkos::RangePolicy<CpExec>(0, ng),
1194 KOKKOS_LAMBDA(int j) { realIndices(no + j) = canon(j); });
1195}
1196
1206class ParticleHalo {
1207 public:
1208 // Block decomposition over the GLOBAL domain (the per-block solver stays non-periodic; the halo
1209 // supplies the periodic wrap). gsize is the ORB cell grid. Mirrors MpiParticleHalo::init.
1210 void initMpi(std::array<double, 3> origin, std::array<double, 3> size, std::array<long, 3> gsize,
1211 std::array<bool, 3> periodic, MPI_Comm comm) {
1212 comm_ = comm;
1213 int sz = 1;
1214 MPI_Comm_rank(comm_, &rank_);
1215 MPI_Comm_size(comm_, &sz);
1216 dec_.init(static_cast<std::size_t>(sz), peclet::core::IVec<3>{gsize[0], gsize[1], gsize[2]});
1217 peclet::core::halo::DomainMap<3> map;
1218 for (int i = 0; i < 3; ++i) {
1219 map.origin[i] = origin[i];
1220 map.cellSize[i] = size[i] / static_cast<double>(gsize[i]);
1221 map.periodic[i] = periodic[i];
1222 }
1223 map_ = map;
1224 mig_.init(dec_, rank_, map, comm_);
1225 halo_.init(mig_);
1226 inited_ = true;
1227 }
1228 // Shared-decomposition overload: adopt an EXTERNALLY-built ORB (so dem shares one BlockDecomposer
1229 // with the flow solver in a coupled run, and migrateTo() can move onto a re-decomposed
1230 // partition). `size`/`origin` map the ORB cell grid (= dec.globalSize()) to the physical domain.
1231 void initMpi(const peclet::core::decomp::BlockDecomposer<3>& dec, std::array<double, 3> origin,
1232 std::array<double, 3> size, std::array<bool, 3> periodic, MPI_Comm comm) {
1233 comm_ = comm;
1234 MPI_Comm_rank(comm_, &rank_);
1235 dec_ = dec;
1236 const auto& gs = dec.globalSize();
1237 peclet::core::halo::DomainMap<3> map;
1238 for (int i = 0; i < 3; ++i) {
1239 map.origin[i] = origin[i];
1240 map.cellSize[i] = size[i] / static_cast<double>(gs[i]);
1241 map.periodic[i] = periodic[i];
1242 }
1243 map_ = map;
1244 mig_.init(dec_, rank_, map, comm_);
1245 halo_.init(mig_);
1246 inited_ = true;
1247 }
1248 const peclet::core::decomp::BlockDecomposer<3>& decomposer() const { return dec_; }
1249
1253 int rankColor() const { return rankColor_; }
1254 int numRankColors() const { return numRankColors_; }
1255
1256 bool inited() const { return inited_; }
1257 int rank() const { return rank_; }
1258 int numGhost() const { return numGhost_; }
1259
1267 void setVerletSkin(float skin) { verletSkin_ = skin < 0.0f ? 0.0f : skin; }
1268 float verletSkin() const { return verletSkin_; }
1272 void invalidateTopology() { haveTopo_ = false; }
1275 long numRebuilds() const { return nRebuild_; }
1276 long numGathers() const { return nGather_; }
1277
1278 // Rebuild the owner<->ghost correspondence over the current owned positions and populate the
1279 // ghost slots [numReal, numReal+numGhost) with the owners' full forwarded state. Sets
1280 // P.numParticles. Returns numReal+numGhost. Faithful port of Simulation::mpi_gather_ghosts.
1281 int gather(Particles& P, double rcut) {
1282 const int no = P.numReal;
1283 ++nGather_;
1284
1285 // Decide whether to rebuild the owner↔ghost topology or reuse the cached one (Verlet skin, D2).
1286 // A rebuild is forced when reuse is off (skin==0), on the first gather, when the owned count
1287 // changed (a migration happened ⇒ topology invalid), when the band differs from the one the
1288 // cached lists were built with (enable_mpi_step again with another rcut or skin, or the
1289 // default band following the particle size -- a list built with a narrower band lacks the
1290 // ghosts the new one needs), or when an owned particle has displaced ≥ skin since the last
1291 // build (a particle could have entered the rcut band without being in the rcut+skin list).
1292 const double band = rcut + static_cast<double>(verletSkin_);
1293 ensureRankColoring(band);
1294 // Rank-level M (§13.3) is per substep: nothing of it crosses a gather.
1295 velM_ = posM_ = orphanShares_ = false;
1296 bool rebuild = (verletSkin_ <= 0.0f) || !haveTopo_ || (no != lastNumReal_) || band != lastBand_;
1297 if (!rebuild && maxOwnedDisplacement(P.pos, no) >= verletSkin_)
1298 rebuild = true;
1299 // The build is collective (an NBX round) and the forwards run over the topology it fixes, but
1300 // every test above except `skin <= 0` is rank-local: one rank's particles can cross the skin
1301 // while its neighbours' rest. So the decision is global -- any rank rebuilding makes all ranks
1302 // rebuild (a rank-local rebuild deadlocks its NBX against the neighbours' forwards). With
1303 // reuse off every rank rebuilds unconditionally, and no reduction is needed.
1304 if (verletSkin_ > 0.0f) {
1305 int mine = rebuild ? 1 : 0, any = 0;
1306 MPI_Allreduce(&mine, &any, 1, MPI_INT, MPI_LOR, comm_);
1307 rebuild = any != 0;
1308 }
1309 numReal_ = no;
1310
1311 if (rebuild) {
1312 ++nRebuild_;
1313 // (1) download owned positions, (re)build the host halo topology, capture it on device.
1314 auto hpos = Kokkos::create_mirror_view(P.pos);
1315 Kokkos::deep_copy(hpos, P.pos);
1316 std::vector<peclet::core::Vec<3>> pv(static_cast<std::size_t>(no));
1317 // Ghost SELECTION runs on the ownership coordinate: on a non-periodic axis a position
1318 // outside the domain is clamped onto it, exactly as core's cellOf clamps it for ownership
1319 // (docs/contact_solve_framework.md §5, §12 S20). Clamping is a projection onto a convex box,
1320 // so it never lengthens a distance: every pair within the reach stays within the band of
1321 // its partner's owner. The ghosts' forwarded positions are the true ones (the image shift
1322 // on a non-periodic axis is 0 either way).
1323 const auto& gsz = dec_.globalSize();
1324 for (int i = 0; i < no; ++i) {
1325 double q[3] = {hpos(i, 0), hpos(i, 1), hpos(i, 2)};
1326 for (int d = 0; d < 3; ++d)
1327 if (!map_.periodic[d])
1328 q[d] = std::clamp(q[d], map_.origin[d],
1329 map_.origin[d] + static_cast<double>(gsz[d]) * map_.cellSize[d]);
1330 pv[i] = peclet::core::Vec<3>{q[0], q[1], q[2]};
1331 }
1332 // includePeriodicSelf: a rank that owns a full (undecomposed) periodic axis -- a "x1" ORB
1333 // axis (e.g. z of a 2x2x1 layout) or np=1 -- is its own periodic image on that axis, so the
1334 // periodic neighbours are local self-ghosts the cross-rank exchange never makes. This
1335 // supplies them.
1336 // allImages (docs/contact_solve_framework.md §5.3, WO-9): EVERY periodic image of a particle
1337 // within the band goes to a destination rank, one ghost per image -- a pair that wraps across
1338 // an undecomposed periodic axis needs a second image on the same rank, which the one-image
1339 // rule dropped (FOLLOWUPS 4b). Reverse sums every image's increment onto the owner.
1340 halo_.build(pv, band, /*includePeriodicSelf=*/true, /*allImages=*/true);
1341 dev_.init(halo_);
1342 const int ng = static_cast<int>(halo_.numGhost());
1343 // The halo topology (forward / device self-gather) writes ALL ng ghost slots [no, no+ng); the
1344 // Particles SoA must have room for them. Silently truncating ng here would leave the halo
1345 // writing past the truncated count -> out-of-bounds SoA writes (memory corruption, not a
1346 // clean drop). So require adequate capacity and fail loudly instead. Size Simulation capacity
1347 // for the worst-case ghost band (a fully periodic box at rcut+skin needs a thick boundary
1348 // layer of ghosts).
1349 if (no + ng > P.capacity)
1350 throw std::runtime_error(
1351 "ParticleHalo::gather: ghost overflow -- need capacity >= " + std::to_string(no + ng) +
1352 " (numReal=" + std::to_string(no) + " + numGhost=" + std::to_string(ng) + "), have " +
1353 std::to_string(P.capacity) + "; increase the Simulation capacity.");
1354 const auto topo = halo_.flatten();
1355 numGhost_ = ng;
1356 numSend_ = static_cast<int>(topo.sendIdx.size());
1357 allocBuffers(no, ng);
1358 uploadShift(topo);
1359 const std::vector<int> hSlot = uploadGhostSlots(topo, ng);
1360 buildOwnershipMaps(topo, no, ng, hSlot);
1361 numReceived_ = static_cast<int>(topo.numReceived);
1362 selfIdx_.assign(topo.selfIdx.begin(), topo.selfIdx.end());
1363 buildInterfaceRows(topo, no);
1364 // Snapshot the owned positions at build time — the reference for the displacement check.
1365 if (verletSkin_ > 0.0f) {
1366 if (refPos_.extent(0) < static_cast<std::size_t>(no))
1367 refPos_ = V3("peclet::dem::halo::refPos", static_cast<std::size_t>(P.capacity));
1368 Kokkos::deep_copy(Kokkos::subview(refPos_, std::pair<int, int>(0, no), Kokkos::ALL),
1369 Kokkos::subview(P.pos, std::pair<int, int>(0, no), Kokkos::ALL));
1370 }
1371 haveTopo_ = true;
1372 lastNumReal_ = no;
1373 lastBand_ = band;
1374 canonStale_ = true; // the velocity slot map follows the new ghost set (mapVelocitySlots)
1375 }
1376
1377 const int ng = numGhost_;
1378 P.numParticles = no + ng;
1379 // self-map realIndices for the reals (owner deltas land on themselves); done every step like
1380 // demStep.
1381 selfMapReals(P.realIndices, no);
1382 if (!exchanges())
1383 return no;
1384
1385 // (2) positions (committed + predicted) with the periodic image shift. d_pos_pred was already
1386 // advanced by predict_velocity, so it is forwarded too (NOT copied from pos) -- see CUDA
1387 // comment.
1388 forwardPositions(P.pos);
1389 forwardPositions(P.posPred);
1390
1391 // (3) all other state packed into one record -> single exchange -> ghost slots + self-mapped
1392 // idx.
1393 haloPackGather(P.vel, P.velPred, P.angVel, P.angVelPred, P.invInertia, P.quat, P.quatPred,
1394 P.scale, P.invMass, P.shapeId, P.gid, P.materialId, P.groundedLevel,
1395 P.bodyOrphan, P.bodyOrphanVPeak, ownedPack_, no);
1396 dev_.forward(ownedPack_, ghostPack_);
1397 haloUnpackGather(P.vel, P.velPred, P.angVel, P.angVelPred, P.invInertia, P.quat, P.quatPred,
1398 P.scale, P.invMass, P.shapeId, P.realIndices, P.gid, P.materialId,
1399 P.groundedLevel, P.bodyOrphan, P.bodyOrphanVPeak, ghostPack_, ghostSlot_, no,
1400 ng);
1401 return no + ng;
1402 }
1403
1404 MPI_Comm comm() const { return comm_; }
1408 bool exchanges() const { return numGhost_ > 0 || numSend_ > 0; }
1409
1410 // Dynamic load re-balance: re-decompose the ORB by per-block particle COUNT (weighted ORB) and
1411 // migrate each owned particle, with its committed state, to its new owner. A pure redistribution
1412 // of the same global particle set (count conserved, per-particle state preserved) — only
1413 // ownership and this rank's owned slice change; the physics result is unchanged. Must be called
1414 // at a step boundary (committed pos/quat/vel/angVel valid; predicted/delta/ghost scratch are
1415 // rebuilt next gather()). Returns this rank's new owned count. No-op-safe at np=1.
1416 // Weighted re-decompose by particle count (the equal-cell ORB imbalances as particles cluster)
1417 // and migrate owners. dec_ updated in place; mig_ points to it. Returns the new owned count.
1418 int rebalance(Particles& P) {
1419 std::vector<peclet::core::Vec<3>> pos;
1420 std::vector<char> payload;
1421 packState(P, pos, payload);
1422 const std::size_t newN = peclet::core::halo::rebalanceByParticleCount(
1423 dec_, mig_, pos, payload, sizeof(MigratePack), comm_);
1424 orderArrivalsBySource(pos, payload, newN);
1425 if ((int)newN > P.capacity)
1426 throw std::runtime_error("ParticleHalo::rebalance: owned overflow -- rank received " +
1427 std::to_string(newN) + " particles, capacity " +
1428 std::to_string(P.capacity));
1429 unpackState(P, pos, payload, newN);
1430 return (int)newN;
1431 }
1438 int migrateToBlocks(Particles& P) {
1439 std::vector<peclet::core::Vec<3>> pos;
1440 std::vector<char> payload;
1441 packState(P, pos, payload);
1442 const std::size_t newN = mig_.migrate(pos, payload, sizeof(MigratePack));
1443 orderArrivalsBySource(pos, payload, newN);
1444 if ((int)newN > P.capacity)
1445 throw std::runtime_error("ParticleHalo::migrateToBlocks: owned overflow -- rank received " +
1446 std::to_string(newN) + " particles, capacity " +
1447 std::to_string(P.capacity));
1448 unpackState(P, pos, payload, newN);
1449 haveTopo_ = false;
1450 return (int)newN;
1451 }
1454 struct BlockBox {
1455 F3 lo, hi, period;
1456 };
1457 BlockBox blockBox() const {
1458 const auto blk = dec_.block(static_cast<std::size_t>(rank_));
1459 const auto& gs = dec_.globalSize();
1460 float lo[3], hi[3], L[3];
1461 for (int d = 0; d < 3; ++d) {
1462 lo[d] = static_cast<float>(map_.origin[d] +
1463 static_cast<double>(blk.origin[d]) * map_.cellSize[d]);
1464 hi[d] = static_cast<float>(map_.origin[d] + static_cast<double>(blk.origin[d] + blk.size[d]) *
1465 map_.cellSize[d]);
1466 L[d] = map_.periodic[d] ? static_cast<float>(static_cast<double>(gs[d]) * map_.cellSize[d])
1467 : 0.0f;
1468 // Ownership clamps a position outside a non-periodic domain onto the boundary cells
1469 // (core ParticleMigrator::cellOf), so a boundary block owns the half-space beyond it.
1470 if (!map_.periodic[d]) {
1471 if (blk.origin[d] == 0)
1472 lo[d] = -std::numeric_limits<float>::infinity();
1473 if (blk.origin[d] + blk.size[d] == gs[d])
1474 hi[d] = std::numeric_limits<float>::infinity();
1475 }
1476 }
1477 return BlockBox{F3{lo[0], lo[1], lo[2]}, F3{hi[0], hi[1], hi[2]}, F3{L[0], L[1], L[2]}};
1478 }
1479 // Migrate particles onto an EXTERNALLY-supplied decomposition (dynamic co-rebalancing: the same
1480 // BlockDecomposer the flow solver redistributes onto). Pure ownership move — counts/state
1481 // conserved. Must be called at a step boundary. No-op-safe at np=1.
1482 int migrateTo(Particles& P, const peclet::core::decomp::BlockDecomposer<3>& newDec) {
1483 std::vector<peclet::core::Vec<3>> pos;
1484 std::vector<char> payload;
1485 packState(P, pos, payload);
1486 dec_ =
1487 newDec; // in place: mig_ still points at dec_; mig_.migrate() sends to dec_.ownerOf(...)
1488 colorBand_ = -1.0; // the rank colouring follows the decomposition (§1.4)
1489 const std::size_t newN = mig_.migrate(pos, payload, sizeof(MigratePack));
1490 orderArrivalsBySource(pos, payload, newN);
1491 if ((int)newN > P.capacity)
1492 throw std::runtime_error("ParticleHalo::migrateTo: owned overflow -- rank received " +
1493 std::to_string(newN) + " particles, capacity " +
1494 std::to_string(P.capacity));
1495 unpackState(P, pos, payload, newN);
1496 return (int)newN;
1497 }
1498 // Migrate onto the weighted ORB of per-cell weights `w` (global x-fastest, matching the ORB
1499 // grid). The Lagrangian half of the co-rebalance: dem builds the SAME deterministic partition
1500 // flow does from the same weight field, so no BlockDecomposer object crosses the language
1501 // boundary. `align` > 1 builds core's ALIGNED weighted ORB (every split plane on a multiple of
1502 // `align` cells, coarse-first: amr/docs/amr_mg_core_boundary.md §11.4) -- the partition flow's
1503 // rebalanceByWeights chose for its pressure multigrid, which dem must reproduce to stay
1504 // co-located. `align` == 1 is the plain weighted ORB, exactly the pre-`align` call. The caller
1505 // (Simulation::migrateToWeights) has validated `w` and `align` against the ORB grid.
1506 int migrateToWeights(Particles& P, const std::vector<peclet::core::Real>& w, int align = 1) {
1507 int size = 1;
1508 MPI_Comm_size(comm_, &size);
1509 if (align == 1) {
1510 peclet::core::decomp::BlockDecomposer<3> newDec((std::size_t)size, dec_.globalSize(), w);
1511 return migrateTo(P, newDec);
1512 }
1513 peclet::core::decomp::BlockDecomposer<3> newDec;
1514 newDec.init((std::size_t)size, dec_.globalSize(), w,
1515 peclet::core::IVec<3>{align, align, align});
1516 return migrateTo(P, newDec);
1517 }
1518
1519 private:
1520 // Download the committed state and pack it (position drives ownership; the rest is the payload).
1521 // Each particle also carries its slice of the persistent-contact ledger (see MigratePack).
1522 void packState(Particles& P, std::vector<peclet::core::Vec<3>>& pos, std::vector<char>& payload) {
1523 const int no = P.numReal;
1524 auto h_pos = Kokkos::create_mirror_view(P.pos);
1525 auto h_quat = Kokkos::create_mirror_view(P.quat);
1526 auto h_vel = Kokkos::create_mirror_view(P.vel);
1527 auto h_angVel = Kokkos::create_mirror_view(P.angVel);
1528 auto h_invI = Kokkos::create_mirror_view(P.invInertia);
1529 auto h_invM = Kokkos::create_mirror_view(P.invMass);
1530 auto h_scale = Kokkos::create_mirror_view(P.scale);
1531 auto h_tScale = Kokkos::create_mirror_view(P.targetScale);
1532 auto h_shape = Kokkos::create_mirror_view(P.shapeId);
1533 auto h_pf = Kokkos::create_mirror_view(P.planeFriction);
1534 auto h_gid = Kokkos::create_mirror_view(P.gid);
1535 auto h_mat = Kokkos::create_mirror_view(P.materialId);
1536 auto h_grd = Kokkos::create_mirror_view(P.groundedLevel);
1537 Kokkos::deep_copy(h_pos, P.pos);
1538 Kokkos::deep_copy(h_quat, P.quat);
1539 Kokkos::deep_copy(h_vel, P.vel);
1540 Kokkos::deep_copy(h_angVel, P.angVel);
1541 Kokkos::deep_copy(h_invI, P.invInertia);
1542 Kokkos::deep_copy(h_invM, P.invMass);
1543 Kokkos::deep_copy(h_scale, P.scale);
1544 Kokkos::deep_copy(h_tScale, P.targetScale);
1545 Kokkos::deep_copy(h_shape, P.shapeId);
1546 Kokkos::deep_copy(h_pf, P.planeFriction);
1547 Kokkos::deep_copy(h_gid, P.gid);
1548 Kokkos::deep_copy(h_mat, P.materialId);
1549 Kokkos::deep_copy(h_grd, P.groundedLevel);
1550 auto h_orp = Kokkos::create_mirror_view(P.bodyOrphan);
1551 auto h_ovp = Kokkos::create_mirror_view(P.bodyOrphanVPeak);
1552 Kokkos::deep_copy(h_orp, P.bodyOrphan);
1553 Kokkos::deep_copy(h_ovp, P.bodyOrphanVPeak);
1554 auto h_ef = Kokkos::create_mirror_view(P.extForce);
1555 auto h_et = Kokkos::create_mirror_view(P.extTorque);
1556 Kokkos::deep_copy(h_ef, P.extForce);
1557 Kokkos::deep_copy(h_et, P.extTorque);
1558 pos.assign((std::size_t)no, peclet::core::Vec<3>{});
1559 payload.assign((std::size_t)no * sizeof(MigratePack), 0);
1560 std::vector<MigratePack> packs((std::size_t)no);
1561 std::unordered_map<unsigned, int> gidToLocal;
1562 gidToLocal.reserve((std::size_t)no * 2);
1563 for (int i = 0; i < no; ++i) {
1564 pos[(std::size_t)i] = peclet::core::Vec<3>{h_pos(i, 0), h_pos(i, 1), h_pos(i, 2)};
1565 MigratePack& m = packs[(std::size_t)i];
1566 m.quat = F4{h_quat(i, 0), h_quat(i, 1), h_quat(i, 2), h_quat(i, 3)};
1567 m.vel = F3{h_vel(i, 0), h_vel(i, 1), h_vel(i, 2)};
1568 m.angVel = F3{h_angVel(i, 0), h_angVel(i, 1), h_angVel(i, 2)};
1569 m.invInertia = F3{h_invI(i, 0), h_invI(i, 1), h_invI(i, 2)};
1570 m.invMass = h_invM(i);
1571 m.scale = h_scale(i);
1572 m.targetScale = h_tScale(i);
1573 m.shapeId = h_shape(i);
1574 m.planeFric0 = h_pf(i, 0);
1575 m.planeFric1 = h_pf(i, 1);
1576 m.gid = h_gid(i);
1577 m.srcRank = rank_;
1578 m.materialId = h_mat(i);
1579 m.groundedLevel = h_grd(i);
1580 m.orphan = h_orp(i);
1581 m.orphanVPeak = h_ovp(i);
1582 m.extForce = F3{h_ef(i, 0), h_ef(i, 1), h_ef(i, 2)};
1583 m.extTorque = F3{h_et(i, 0), h_et(i, 1), h_et(i, 2)};
1584 m.numWarm = 0;
1585 gidToLocal.emplace(static_cast<unsigned>(h_gid(i)), i);
1586 }
1587 // Distribute the previous-substep converged ledger onto its endpoint particles: each pair
1588 // rides on BOTH locally-owned endpoints (after the move either endpoint's new rank may own the
1589 // pair; the unpack dedupes by key). Beyond kWarmCarryMax the lowest-|impulse| entry is
1590 // evicted — a dropped pair merely warm-starts cold on the receiving rank.
1591 if (P.prevPairCount > 0) {
1592 const int pc = P.prevPairCount;
1593 // Full-view mirrors (not row-range subviews): a row range of a LayoutLeft float*[3] view is
1594 // non-contiguous, and a device->host deep_copy of it has no copy mechanism on CUDA.
1595 auto h_k = Kokkos::create_mirror_view(P.prevPairKeys);
1596 auto h_l = Kokkos::create_mirror_view(P.prevLambda);
1597 auto h_lt = Kokkos::create_mirror_view(P.prevLambdaT);
1598 auto h_pi = Kokkos::create_mirror_view(P.prevPosImpulse);
1599 auto h_rb = Kokkos::create_mirror_view(P.prevRestBank);
1600 auto h_rv = Kokkos::create_mirror_view(P.prevRestVPeak);
1601 Kokkos::deep_copy(h_k, P.prevPairKeys);
1602 Kokkos::deep_copy(h_l, P.prevLambda);
1603 Kokkos::deep_copy(h_lt, P.prevLambdaT);
1604 Kokkos::deep_copy(h_pi, P.prevPosImpulse);
1605 Kokkos::deep_copy(h_rb, P.prevRestBank);
1606 Kokkos::deep_copy(h_rv, P.prevRestVPeak);
1607 auto attach = [&](int i, const WarmPairEntry& e, float w) {
1608 MigratePack& m = packs[(std::size_t)i];
1609 if (m.numWarm < kWarmCarryMax) {
1610 m.warm[m.numWarm++] = e;
1611 return;
1612 }
1613 int worst = 0;
1614 float worstW = 1e30f;
1615 for (int s = 0; s < kWarmCarryMax; ++s) {
1616 const float ws = std::fabs(m.warm[s].lambda) + std::fabs(m.warm[s].posImpulse);
1617 if (ws < worstW) {
1618 worstW = ws;
1619 worst = s;
1620 }
1621 }
1622 if (w > worstW)
1623 m.warm[worst] = e;
1624 };
1625 for (int e = 0; e < pc; ++e) {
1626 const unsigned long long k = h_k(e);
1627 if (k == ~0ull)
1628 continue;
1629 WarmPairEntry we;
1630 we.key = k;
1631 we.lambda = h_l(e);
1632 we.lambdaT[0] = h_lt(e, 0);
1633 we.lambdaT[1] = h_lt(e, 1);
1634 we.lambdaT[2] = h_lt(e, 2);
1635 we.posImpulse = h_pi(e);
1636 we.restBank = h_rb(e);
1637 we.restVPeak = h_rv(e);
1638 if (we.lambda == 0.0f && we.posImpulse == 0.0f && we.lambdaT[0] == 0.0f &&
1639 we.lambdaT[1] == 0.0f && we.lambdaT[2] == 0.0f && we.restBank == 0.0f)
1640 continue; // dead entry: carrying it only evicts live ones
1641 const float w = std::fabs(we.lambda) + std::fabs(we.posImpulse) + std::fabs(we.restBank);
1642 const unsigned hi = static_cast<unsigned>(k >> 32);
1643 const unsigned lo = static_cast<unsigned>(k & 0xFFFFFFFFu);
1644 if (auto it = gidToLocal.find(hi); it != gidToLocal.end())
1645 attach(it->second, we, w);
1646 if (lo != 0xFFFFFFFFu)
1647 if (auto it = gidToLocal.find(lo); it != gidToLocal.end())
1648 attach(it->second, we, w);
1649 }
1650 }
1651 // Force-engine (Hertz–Mindlin) history. Pack the LIVE cached-pair springs (hertzKeys/hertzXi
1652 // hold the current values; keys are gid-based) onto their locally-owned endpoints, and each
1653 // particle's wall-history slots verbatim.
1654 if (P.hertzNumPairs > 0) {
1655 const int hn = P.hertzNumPairs;
1656 auto h_hk = Kokkos::create_mirror_view(P.hertzKeys);
1657 auto h_hx = Kokkos::create_mirror_view(P.hertzXi);
1658 Kokkos::deep_copy(h_hk, P.hertzKeys);
1659 Kokkos::deep_copy(h_hx, P.hertzXi);
1660 auto attachHertz = [&](int i, const HertzPairEntry& e, float w) {
1661 MigratePack& m = packs[(std::size_t)i];
1662 if (m.numHertz < kWarmCarryMax) {
1663 m.hertz[m.numHertz++] = e;
1664 return;
1665 }
1666 int worst = 0;
1667 float worstW = 1e30f;
1668 for (int s = 0; s < kWarmCarryMax; ++s) {
1669 const float ws = std::fabs(m.hertz[s].xi[0]) + std::fabs(m.hertz[s].xi[1]) +
1670 std::fabs(m.hertz[s].xi[2]);
1671 if (ws < worstW) {
1672 worstW = ws;
1673 worst = s;
1674 }
1675 }
1676 if (w > worstW)
1677 m.hertz[worst] = e;
1678 };
1679 for (int e = 0; e < hn; ++e) {
1680 HertzPairEntry he;
1681 he.key = h_hk(e);
1682 he.xi[0] = h_hx(e, 0);
1683 he.xi[1] = h_hx(e, 1);
1684 he.xi[2] = h_hx(e, 2);
1685 if (he.xi[0] == 0.0f && he.xi[1] == 0.0f && he.xi[2] == 0.0f)
1686 continue; // open / historyless pair: nothing worth carrying
1687 const float w = std::fabs(he.xi[0]) + std::fabs(he.xi[1]) + std::fabs(he.xi[2]);
1688 const unsigned hi = static_cast<unsigned>(he.key >> 32);
1689 const unsigned lo = static_cast<unsigned>(he.key & 0xFFFFFFFFu);
1690 if (auto it = gidToLocal.find(hi); it != gidToLocal.end())
1691 attachHertz(it->second, he, w);
1692 if (auto it = gidToLocal.find(lo); it != gidToLocal.end())
1693 attachHertz(it->second, he, w);
1694 }
1695 }
1696 {
1697 auto h_xw = Kokkos::create_mirror_view(P.hertzXiWall);
1698 auto h_sw = Kokkos::create_mirror_view(P.hertzSnWall);
1699 Kokkos::deep_copy(h_xw, P.hertzXiWall);
1700 Kokkos::deep_copy(h_sw, P.hertzSnWall);
1701 for (int i = 0; i < no; ++i)
1702 for (int wi = 0; wi < Particles::kHertzMaxWalls; ++wi) {
1703 const int slot = i * Particles::kHertzMaxWalls + wi;
1704 MigratePack& m = packs[(std::size_t)i];
1705 m.hertzXiWall[wi][0] = h_xw(slot, 0);
1706 m.hertzXiWall[wi][1] = h_xw(slot, 1);
1707 m.hertzXiWall[wi][2] = h_xw(slot, 2);
1708 m.hertzSnWall[wi] = h_sw(slot);
1709 }
1710 }
1711 for (int i = 0; i < no; ++i)
1712 std::memcpy(&payload[(std::size_t)i * sizeof(MigratePack)], &packs[(std::size_t)i],
1713 sizeof(MigratePack));
1714 }
1715 // Unpack the migrated particles back into the SoA [0,newN) and upload; rebuild the rank's
1716 // persistent-contact ledger (prevPairKeys sorted + aligned impulse stores) from the union of the
1717 // arriving particles' carried slices.
1718 void unpackState(Particles& P, const std::vector<peclet::core::Vec<3>>& pos,
1719 const std::vector<char>& payload, std::size_t newN) {
1720 auto h_pos = Kokkos::create_mirror_view(P.pos);
1721 auto h_quat = Kokkos::create_mirror_view(P.quat);
1722 auto h_vel = Kokkos::create_mirror_view(P.vel);
1723 auto h_angVel = Kokkos::create_mirror_view(P.angVel);
1724 auto h_invI = Kokkos::create_mirror_view(P.invInertia);
1725 auto h_invM = Kokkos::create_mirror_view(P.invMass);
1726 auto h_scale = Kokkos::create_mirror_view(P.scale);
1727 auto h_tScale = Kokkos::create_mirror_view(P.targetScale);
1728 auto h_shape = Kokkos::create_mirror_view(P.shapeId);
1729 auto h_pf = Kokkos::create_mirror_view(P.planeFriction);
1730 auto h_gid = Kokkos::create_mirror_view(P.gid);
1731 auto h_mat = Kokkos::create_mirror_view(P.materialId);
1732 auto h_grd = Kokkos::create_mirror_view(P.groundedLevel);
1733 auto h_orp = Kokkos::create_mirror_view(P.bodyOrphan);
1734 auto h_ovp = Kokkos::create_mirror_view(P.bodyOrphanVPeak);
1735 Kokkos::deep_copy(h_orp, P.bodyOrphan); // slots past newN keep defined values
1736 Kokkos::deep_copy(h_ovp, P.bodyOrphanVPeak);
1737 auto h_ef = Kokkos::create_mirror_view(P.extForce);
1738 auto h_et = Kokkos::create_mirror_view(P.extTorque);
1739 Kokkos::deep_copy(h_ef, P.extForce);
1740 Kokkos::deep_copy(h_et, P.extTorque);
1741 std::vector<WarmPairEntry> ledger;
1742 ledger.reserve(newN * 4);
1743 std::vector<HertzPairEntry> hertzLedger;
1744 hertzLedger.reserve(newN * 4);
1745 auto h_xw = Kokkos::create_mirror_view(P.hertzXiWall);
1746 auto h_sw = Kokkos::create_mirror_view(P.hertzSnWall);
1747 Kokkos::deep_copy(h_xw, P.hertzXiWall); // slots past newN keep defined values on CUDA mirrors
1748 Kokkos::deep_copy(h_sw, P.hertzSnWall);
1749 for (std::size_t i = 0; i < newN; ++i) {
1750 h_pos((int)i, 0) = pos[i][0];
1751 h_pos((int)i, 1) = pos[i][1];
1752 h_pos((int)i, 2) = pos[i][2];
1753 MigratePack m;
1754 std::memcpy(&m, &payload[i * sizeof(MigratePack)], sizeof(MigratePack));
1755 h_quat((int)i, 0) = m.quat.x;
1756 h_quat((int)i, 1) = m.quat.y;
1757 h_quat((int)i, 2) = m.quat.z;
1758 h_quat((int)i, 3) = m.quat.w;
1759 h_vel((int)i, 0) = m.vel.x;
1760 h_vel((int)i, 1) = m.vel.y;
1761 h_vel((int)i, 2) = m.vel.z;
1762 h_angVel((int)i, 0) = m.angVel.x;
1763 h_angVel((int)i, 1) = m.angVel.y;
1764 h_angVel((int)i, 2) = m.angVel.z;
1765 h_invI((int)i, 0) = m.invInertia.x;
1766 h_invI((int)i, 1) = m.invInertia.y;
1767 h_invI((int)i, 2) = m.invInertia.z;
1768 h_invM((int)i) = m.invMass;
1769 h_scale((int)i) = m.scale;
1770 h_tScale((int)i) = m.targetScale;
1771 h_shape((int)i) = m.shapeId;
1772 h_pf((int)i, 0) = m.planeFric0;
1773 h_pf((int)i, 1) = m.planeFric1;
1774 h_gid((int)i) = m.gid;
1775 h_mat((int)i) = m.materialId;
1776 h_grd((int)i) = m.groundedLevel;
1777 h_orp((int)i) = m.orphan;
1778 h_ovp((int)i) = m.orphanVPeak;
1779 h_ef((int)i, 0) = m.extForce.x;
1780 h_ef((int)i, 1) = m.extForce.y;
1781 h_ef((int)i, 2) = m.extForce.z;
1782 h_et((int)i, 0) = m.extTorque.x;
1783 h_et((int)i, 1) = m.extTorque.y;
1784 h_et((int)i, 2) = m.extTorque.z;
1785 for (int s = 0; s < (int)m.numWarm && s < kWarmCarryMax; ++s)
1786 ledger.push_back(m.warm[s]);
1787 for (int s = 0; s < (int)m.numHertz && s < kWarmCarryMax; ++s)
1788 hertzLedger.push_back(m.hertz[s]);
1789 for (int wi = 0; wi < Particles::kHertzMaxWalls; ++wi) {
1790 const int slot = (int)i * Particles::kHertzMaxWalls + wi;
1791 h_xw(slot, 0) = m.hertzXiWall[wi][0];
1792 h_xw(slot, 1) = m.hertzXiWall[wi][1];
1793 h_xw(slot, 2) = m.hertzXiWall[wi][2];
1794 h_sw(slot) = m.hertzSnWall[wi];
1795 }
1796 }
1797 Kokkos::deep_copy(P.pos, h_pos);
1798 Kokkos::deep_copy(P.quat, h_quat);
1799 Kokkos::deep_copy(P.vel, h_vel);
1800 Kokkos::deep_copy(P.angVel, h_angVel);
1801 Kokkos::deep_copy(P.invInertia, h_invI);
1802 Kokkos::deep_copy(P.invMass, h_invM);
1803 Kokkos::deep_copy(P.scale, h_scale);
1804 Kokkos::deep_copy(P.targetScale, h_tScale);
1805 Kokkos::deep_copy(P.shapeId, h_shape);
1806 Kokkos::deep_copy(P.planeFriction, h_pf);
1807 Kokkos::deep_copy(P.gid, h_gid);
1808 Kokkos::deep_copy(P.materialId, h_mat);
1809 Kokkos::deep_copy(P.groundedLevel, h_grd);
1810 Kokkos::deep_copy(P.bodyOrphan, h_orp);
1811 Kokkos::deep_copy(P.bodyOrphanVPeak, h_ovp);
1812 Kokkos::deep_copy(P.extForce, h_ef);
1813 Kokkos::deep_copy(P.extTorque, h_et);
1814
1815 // Ledger rebuild: sort by key, dedupe (a pair arrives once per locally-received endpoint; the
1816 // duplicates carry identical values), clamp to the store capacity, upload sorted + aligned —
1817 // exactly the layout the warm-start gather's binary search expects.
1818 std::sort(ledger.begin(), ledger.end(),
1819 [](const WarmPairEntry& a, const WarmPairEntry& b) { return a.key < b.key; });
1820 ledger.erase(
1821 std::unique(ledger.begin(), ledger.end(),
1822 [](const WarmPairEntry& a, const WarmPairEntry& b) { return a.key == b.key; }),
1823 ledger.end());
1824 const int nl = std::min<int>((int)ledger.size(), (int)P.prevPairKeys.extent(0));
1825 {
1826 auto hk = Kokkos::create_mirror_view(P.prevPairKeys);
1827 auto hl = Kokkos::create_mirror_view(P.prevLambda);
1828 auto hlt = Kokkos::create_mirror_view(P.prevLambdaT);
1829 auto hpi = Kokkos::create_mirror_view(P.prevPosImpulse);
1830 auto hrb = Kokkos::create_mirror_view(P.prevRestBank);
1831 auto hrv = Kokkos::create_mirror_view(P.prevRestVPeak);
1832 Kokkos::deep_copy(hk, P.prevPairKeys); // preserve tail entries beyond nl
1833 Kokkos::deep_copy(hl, P.prevLambda);
1834 Kokkos::deep_copy(hlt, P.prevLambdaT);
1835 Kokkos::deep_copy(hpi, P.prevPosImpulse);
1836 Kokkos::deep_copy(hrb, P.prevRestBank);
1837 Kokkos::deep_copy(hrv, P.prevRestVPeak);
1838 for (int e = 0; e < nl; ++e) {
1839 hk(e) = ledger[(std::size_t)e].key;
1840 hl(e) = ledger[(std::size_t)e].lambda;
1841 hlt(e, 0) = ledger[(std::size_t)e].lambdaT[0];
1842 hlt(e, 1) = ledger[(std::size_t)e].lambdaT[1];
1843 hlt(e, 2) = ledger[(std::size_t)e].lambdaT[2];
1844 hpi(e) = ledger[(std::size_t)e].posImpulse;
1845 hrb(e) = ledger[(std::size_t)e].restBank;
1846 hrv(e) = ledger[(std::size_t)e].restVPeak;
1847 }
1848 Kokkos::deep_copy(P.prevPairKeys, hk);
1849 Kokkos::deep_copy(P.prevLambda, hl);
1850 Kokkos::deep_copy(P.prevLambdaT, hlt);
1851 Kokkos::deep_copy(P.prevPosImpulse, hpi);
1852 Kokkos::deep_copy(P.prevRestBank, hrb);
1853 Kokkos::deep_copy(P.prevRestVPeak, hrv);
1854 }
1855 P.prevPairCount = nl;
1856
1857 // Force-engine history rebuild: wall slots verbatim per (new local index, wall); the pair
1858 // ledger sorted + deduped into the hertzPrev store (the exact layout hertzRebuildPairs'
1859 // key-carry binary search expects), and the cached pair LIST invalidated — local pair slots
1860 // reference pre-migration indices, so the next force step must rebuild (and re-gathers the
1861 // halo then). A fresh rank may never have allocated the prev store: size it here.
1862 Kokkos::deep_copy(P.hertzXiWall, h_xw);
1863 Kokkos::deep_copy(P.hertzSnWall, h_sw);
1864 std::sort(hertzLedger.begin(), hertzLedger.end(),
1865 [](const HertzPairEntry& a, const HertzPairEntry& b) { return a.key < b.key; });
1866 hertzLedger.erase(std::unique(hertzLedger.begin(), hertzLedger.end(),
1867 [](const HertzPairEntry& a, const HertzPairEntry& b) {
1868 return a.key == b.key;
1869 }),
1870 hertzLedger.end());
1871 const int nh = (int)hertzLedger.size();
1872 if ((int)P.hertzPrevKeys.extent(0) < nh) {
1873 P.hertzPrevKeys = Kokkos::View<unsigned long long*, CpMem>("hertzPrevKeys", nh);
1874 P.hertzPrevXi = Kokkos::View<float* [3], CpMem>("hertzPrevXi", nh);
1875 }
1876 if (nh > 0) {
1877 auto hk = Kokkos::create_mirror_view(P.hertzPrevKeys);
1878 auto hx = Kokkos::create_mirror_view(P.hertzPrevXi);
1879 Kokkos::deep_copy(hk, P.hertzPrevKeys);
1880 Kokkos::deep_copy(hx, P.hertzPrevXi);
1881 for (int e = 0; e < nh; ++e) {
1882 hk(e) = hertzLedger[(std::size_t)e].key;
1883 hx(e, 0) = hertzLedger[(std::size_t)e].xi[0];
1884 hx(e, 1) = hertzLedger[(std::size_t)e].xi[1];
1885 hx(e, 2) = hertzLedger[(std::size_t)e].xi[2];
1886 }
1887 Kokkos::deep_copy(P.hertzPrevKeys, hk);
1888 Kokkos::deep_copy(P.hertzPrevXi, hx);
1889 }
1890 P.hertzPrevCount = nh;
1891 P.hertzNumPairs = -1;
1892
1893 P.numReal = (int)newN;
1894 P.numParticles = (int)newN;
1895 // The cached owner<->ghost topology (sendIdx, selfIdx, refPos_) is slot-indexed over the
1896 // PRE-migration owned set. Reusing it is still sound when every slot's new occupant lies within
1897 // the skin of that slot's build position (the displacement check is per slot, and every forward
1898 // re-packs the occupant's full state, gid included), but that is an accident of where the
1899 // arrivals land, not an invariant of the migration -- so drop it and let the next gather()
1900 // rebuild. A migration already pays a host round trip; the rebuild adds one NBX round.
1901 haveTopo_ = false;
1902 }
1903
1904 public:
1905 // owner slice [0,numReal) -> ghost slots [numReal,..), verbatim (velocity / angular velocity).
1906 void forward(V3 field) {
1907 if (!exchanges())
1908 return;
1909 haloPackF3(field, ownedF3_, numReal_);
1910 dev_.forward(ownedF3_, ghostF3_);
1911 haloUnpackF3(field, ghostF3_, shiftDev_, ghostSlot_, numReal_, numGhost_, /*doShift=*/false);
1912 }
1913 // owner slice -> ghost slots with the periodic image shift added (positions).
1914 void forwardPositions(V3 field) {
1915 if (!exchanges())
1916 return;
1917 haloPackF3(field, ownedF3_, numReal_);
1918 dev_.forward(ownedF3_, ghostF3_);
1919 haloUnpackF3(field, ghostF3_, shiftDev_, ghostSlot_, numReal_, numGhost_, /*doShift=*/true);
1920 }
1921 // owner slice -> ghost slots, verbatim (quaternions).
1922 void forward4(V4 field) {
1923 if (!exchanges())
1924 return;
1925 haloPackF4(field, ownedF4_, numReal_);
1926 dev_.forward(ownedF4_, ghostF4_);
1927 haloUnpackF4(field, ghostF4_, ghostSlot_, numReal_, numGhost_);
1928 }
1929
1931 ContactOwnership contactOwnership(const Particles& P) const {
1932 return ContactOwnership{rank_, numReal_, P.gid, ghostSource_, copyOffsets_,
1933 copyRanks_, ghostImage_, copyImage_, selfOffsets_, selfImage_};
1934 }
1935
1939 std::vector<float> debugGhostShiftsBySlot() const {
1940 std::vector<float> out(3 * static_cast<std::size_t>(numGhost_ > 0 ? numGhost_ : 0), 0.0f);
1941 if (numGhost_ <= 0)
1942 return out;
1943 auto hs = Kokkos::create_mirror_view_and_copy(Kokkos::HostSpace(), shiftDev_);
1944 auto hk = Kokkos::create_mirror_view_and_copy(Kokkos::HostSpace(), ghostSlot_);
1945 for (int g = 0; g < numGhost_; ++g) {
1946 const int s = hk(g);
1947 out[3 * s] = hs(g).x;
1948 out[3 * s + 1] = hs(g).y;
1949 out[3 * s + 2] = hs(g).z;
1950 }
1951 return out;
1952 }
1953
1954 // ---- ghost -> owner reconciliation (docs/mpi_momentum_conservation.md §2.3) ----
1955 // Every sync below is COLLECTIVE (a reverse, then a forward, over a possibly one-sided
1956 // topology): it may be skipped only when this rank neither sends nor receives (exchanges()),
1957 // never because this rank "wrote no ghost" -- that is rank-local and deadlocks the neighbours.
1958
1960 void markVelocityBaseline(Particles& P) {
1961 if (numGhost_ <= 0)
1962 return;
1963 haloMarkVelocityBaseline(P.velPred, P.angVelPred, P.bodyOrphan, P.bodyOrphanVPeak, baseVel_,
1964 baseAngVel_, baseOrphan_, baseOrphanPeak_, ghostSlot_, numReal_,
1965 numGhost_);
1966 }
1974 void syncVelocities(Particles& P, bool rotation) {
1975 if (!exchanges())
1976 return;
1977 if (velM_) {
1978 syncVelocitiesM(P, rotation);
1979 return;
1980 }
1981 haloPackVelocityIncrement(P.velPred, P.angVelPred, P.bodyOrphan, P.bodyOrphanVPeak, baseVel_,
1982 baseAngVel_, baseOrphan_, baseOrphanPeak_, ghostVelInc_, ghostSlot_,
1983 numReal_, numGhost_);
1984 Kokkos::deep_copy(
1985 Kokkos::subview(ownedVelInc_, std::pair<std::size_t, std::size_t>(0, numReal_)),
1986 VelocityIncrement{});
1987 dev_.reverse(ghostVelInc_, ownedVelInc_);
1988 haloApplyVelocityIncrement(P.velPred, P.angVelPred, P.bodyOrphan, P.bodyOrphanVPeak,
1989 ownedVelInc_, numReal_);
1990 if (rotation) { // one fused exchange of (velPred, angVelPred)
1991 haloPackVelocityState(P.velPred, P.angVelPred, ownedVelState_, nIface_, iface_);
1992 dev_.forward(ownedVelState_, ghostVelState_);
1993 haloUnpackVelocityState(P.velPred, P.angVelPred, ghostVelState_, ghostSlot_, numReal_,
1994 numGhost_);
1995 } else {
1996 forward(P.velPred);
1997 }
1998 markVelocityBaseline(P);
1999 }
2002 void publishPositions(Particles& P, bool rotation) {
2003 if (!exchanges())
2004 return;
2005 forwardPositionState(P, rotation);
2006 markPositionPublished(P);
2007 }
2009 void forwardPositionState(Particles& P, bool rotation) {
2010 if (rotation) { // one fused exchange of (posPred + periodic shift, quatPred)
2011 haloPackPositionState(P.posPred, P.quatPred, ownedPosState_, nIface_, iface_);
2012 dev_.forward(ownedPosState_, ghostPosState_);
2013 haloUnpackPositionState(P.posPred, P.quatPred, ghostPosState_, shiftDev_, ghostSlot_,
2014 numReal_, numGhost_);
2015 } else {
2016 forwardPositions(P.posPred);
2017 }
2018 }
2020 void markPositionPublished(Particles& P) {
2021 haloMarkPositionBaseline(P.posPred, basePos_, ghostSlot_, numReal_, numGhost_);
2022 if (posM_) // rank-level M (§13.3 step 9): the owners' position seeds
2023 haloMarkOwnerPositionSeeds(P.posPred, ownerSeedPos_, nPosRows_, posRows_);
2024 }
2030 void syncPositions(Particles& P, bool rotation) {
2031 if (!exchanges())
2032 return;
2033 if (posM_) { // the fused M reconciliation (MPackZero, reverse, MApply, forward, MUnpack)
2034 haloPositionMPackZero(P.posPred, basePos_, P.aPos, ghostPosInc_, ghostSlot_, numReal_,
2035 numGhost_, ownedPosInc_, posRows_, nPosRows_);
2036 dev_.reverse(ghostPosInc_, ownedPosInc_);
2037 if (rotation)
2038 positionMRound(P, ownedPosState_, ghostPosState_);
2039 else
2040 positionMRound(P, ownedF3_, ghostF3_);
2041 return;
2042 }
2043 haloPackPositionIncrement(P.posPred, basePos_, ghostPosInc_, ghostSlot_, numReal_, numGhost_);
2044 Kokkos::deep_copy(
2045 Kokkos::subview(ownedPosInc_, std::pair<std::size_t, std::size_t>(0, numReal_)),
2046 PositionIncrement{});
2047 dev_.reverse(ghostPosInc_, ownedPosInc_);
2048 haloApplyPositionIncrement(P.posPred, ownedPosInc_, numReal_);
2049 publishPositions(P, rotation);
2050 }
2051
2052 // ---- rank-level M (docs/contact_solve_framework.md §13.3) ----
2054 bool velocityMassSplit() const { return velM_; }
2055 bool positionMassSplit() const { return posM_; }
2056
2064 void openVelocityPhase(Particles& P, bool rotation, bool shares) {
2065 if (!exchanges())
2066 return;
2067 ensureClampCounter(P);
2068 const int no = numReal_, ng = numGhost_;
2069 haloPackOpeningIncrement(P.velPred, P.angVelPred, P.bodyOrphan, P.bodyOrphanVPeak, baseVel_,
2070 baseAngVel_, baseOrphan_, baseOrphanPeak_, P.aVel, P.aPos,
2071 ghostOpenInc_, ghostSlot_, no, ng);
2072 Kokkos::deep_copy(Kokkos::subview(ownedOpenInc_, std::pair<std::size_t, std::size_t>(0, no)),
2073 OpeningIncrement{});
2074 dev_.reverse(ghostOpenInc_, ownedOpenInc_);
2075 haloApplyOpening(P.velPred, P.angVelPred, P.bodyOrphan, P.bodyOrphanVPeak, P.aVel, P.aPos,
2076 P.kVel, P.kPos, ownedOpenInc_, no);
2077 if (rotation) {
2078 haloPackOpeningState(P.velPred, P.angVelPred, P.bodyOrphan, P.bodyOrphanVPeak, P.kVel, P.kPos,
2079 ownedOpenState_, no);
2080 dev_.forward(ownedOpenState_, ghostOpenState_);
2081 haloUnpackOpeningState(P.velPred, P.angVelPred, P.bodyOrphan, P.bodyOrphanVPeak, P.kVel,
2082 P.kPos, ghostOpenState_, ghostSlot_, no, ng);
2083 } else {
2084 haloPackOpeningState(P.velPred, P.angVelPred, P.bodyOrphan, P.bodyOrphanVPeak, P.kVel, P.kPos,
2085 ownedOpenStateNR_, no);
2086 dev_.forward(ownedOpenStateNR_, ghostOpenStateNR_);
2087 haloUnpackOpeningState(P.velPred, P.angVelPred, P.bodyOrphan, P.bodyOrphanVPeak, P.kVel,
2088 P.kPos, ghostOpenStateNR_, ghostSlot_, no, ng);
2089 }
2090 buildMRows(P.kVel, velRows_, nVelRows_);
2091 buildMRows(P.kPos, posRows_, nPosRows_);
2092 velM_ = posM_ = true;
2093 orphanShares_ = shares;
2094 markVelocityReconciled(P);
2095 }
2103 void openPositionCounts(Particles& P, bool velocityMask) {
2104 if (!exchanges())
2105 return;
2106 const int no = numReal_, ng = numGhost_;
2107 if (velocityMask) {
2108 const unsigned long long bit = 1ull << rankColor_;
2109 haloPackGhostCountsMask(P.aVel, P.aPos, ghostCM_, ghostSlot_, bit, no, ng);
2110 Kokkos::deep_copy(Kokkos::subview(ownedCM_, std::pair<std::size_t, std::size_t>(0, no)),
2111 PositionCountsMask{0ull, 0, 0});
2112 dev_.reverse(ghostCM_, ownedCM_);
2113 haloOwnerCountsMask(P.aVel, P.aPos, P.kPos, P.velMask, ownedCM_, bit, no);
2114 dev_.forward(ownedCM_, ghostCM_);
2115 haloUnpackCountsMask(P.kPos, P.velMask, ghostCM_, ghostSlot_, no, ng);
2116 buildMRows(P.kPos, posRows_, nPosRows_);
2117 posM_ = true;
2118 return;
2119 }
2120 haloPackGhostInt(P.aPos, ghostCnt_, ghostSlot_, no, ng);
2121 Kokkos::deep_copy(Kokkos::subview(ownedCnt_, std::pair<std::size_t, std::size_t>(0, no)), 0);
2122 dev_.reverse(ghostCnt_, ownedCnt_);
2123 haloOwnerCountK(P.aPos, P.kPos, ownedCnt_, no);
2124 dev_.forward(ownedCnt_, ghostCnt_);
2125 haloUnpackGhostInt(P.kPos, ghostCnt_, ghostSlot_, no, ng);
2126 buildMRows(P.kPos, posRows_, nPosRows_);
2127 posM_ = true;
2128 }
2130 void restoreOrphanBalance(Particles& P) {
2131 if (!velM_ || !orphanShares_)
2132 return;
2133 haloRestoreOrphanBalance(P.bodyOrphan, ownerSeedOrphan_, P.aVel, P.kVel, nVelRows_, velRows_);
2134 }
2144 void mapVelocitySlots(Particles& P) {
2145 const int no = numReal_, ng = numGhost_;
2146 if (ng <= 0)
2147 return;
2148 if (canonStale_ || ghostCanon_.extent(0) < static_cast<std::size_t>(ng)) {
2149 const int nr = numReceived_; // cross-rank ghost slots [0, nr); self ghosts [nr, ng)
2150 auto hg =
2151 Kokkos::create_mirror_view(Kokkos::subview(P.gid, std::pair<int, int>(no, no + nr)));
2152 Kokkos::deep_copy(hg, Kokkos::subview(P.gid, std::pair<int, int>(no, no + nr)));
2153 std::vector<std::pair<int, int>> key(static_cast<std::size_t>(nr));
2154 for (int g = 0; g < nr; ++g)
2155 key[static_cast<std::size_t>(g)] = {hg(g), g};
2156 std::sort(key.begin(), key.end());
2157 std::vector<int> canon(static_cast<std::size_t>(ng));
2158 for (std::size_t p = 0; p < key.size();) {
2159 std::size_t e = p;
2160 while (e < key.size() && key[e].first == key[p].first)
2161 ++e;
2162 const int c = no + key[p].second; // the lowest ghost slot of this gid
2163 for (std::size_t t = p; t < e; ++t)
2164 canon[static_cast<std::size_t>(key[t].second)] = c;
2165 p = e;
2166 }
2167 for (int g = nr; g < ng; ++g) // a local self ghost: its owned row
2168 canon[static_cast<std::size_t>(g)] = selfIdx_[static_cast<std::size_t>(g - nr)];
2169 ghostCanon_ = peclet::core::toDevice(canon, "peclet::dem::halo::ghostCanon");
2170 canonStale_ = false;
2171 }
2172 haloMapVelocitySlots(P.realIndices, ghostCanon_, no, ng);
2173 }
2174
2175 private:
2184 void ensureRankColoring(double band) {
2185 // Keyed on the band AND a fingerprint of the decomposition, so every way the blocks can
2186 // change (rebalance, migrateTo, migrate_to_weights, a re-init) recolours.
2187 const int nb = static_cast<int>(dec_.numBlocks());
2188 unsigned long long fp = 1469598103934665603ull;
2189 for (int b = 0; b < nb; ++b) {
2190 const auto blk = dec_.block(static_cast<std::size_t>(b));
2191 for (int d = 0; d < 3; ++d) {
2192 fp = (fp ^ static_cast<unsigned long long>(blk.origin[d])) * 1099511628211ull;
2193 fp = (fp ^ static_cast<unsigned long long>(blk.size[d])) * 1099511628211ull;
2194 }
2195 }
2196 if (band == colorBand_ && fp == colorFingerprint_)
2197 return;
2198 colorBand_ = band;
2199 colorFingerprint_ = fp;
2200 const auto& gs = dec_.globalSize();
2201 std::vector<std::array<double, 6>> box(static_cast<std::size_t>(nb));
2202 for (int b = 0; b < nb; ++b) {
2203 const auto blk = dec_.block(static_cast<std::size_t>(b));
2204 for (int d = 0; d < 3; ++d) {
2205 box[b][d] = map_.origin[d] + static_cast<double>(blk.origin[d]) * map_.cellSize[d];
2206 box[b][3 + d] = box[b][d] + static_cast<double>(blk.size[d]) * map_.cellSize[d];
2207 }
2208 }
2209 const double lim2 = 4.0 * band * band;
2210 auto adjacent = [&](int a, int b) {
2211 double d2 = 0.0;
2212 for (int d = 0; d < 3; ++d) {
2213 const double L = static_cast<double>(gs[d]) * map_.cellSize[d];
2214 auto gap = [&](double shift) {
2215 const double loB = box[b][d] + shift, hiB = box[b][3 + d] + shift;
2216 return std::max(0.0, std::max(box[a][d] - hiB, loB - box[a][3 + d]));
2217 };
2218 double g = gap(0.0);
2219 if (map_.periodic[d])
2220 g = std::min(g, std::min(gap(L), gap(-L)));
2221 d2 += g * g;
2222 }
2223 return d2 <= lim2;
2224 };
2225 std::vector<int> col(static_cast<std::size_t>(nb), 0);
2226 int maxc = 0;
2227 for (int r = 0; r < nb; ++r) {
2228 unsigned long long used = 0ull;
2229 for (int q = 0; q < r; ++q)
2230 if (adjacent(r, q))
2231 used |= 1ull << col[q];
2232 int c = 0;
2233 while (c < 64 && ((used >> c) & 1ull))
2234 ++c;
2235 if (c >= 64)
2236 throw std::runtime_error(
2237 "ParticleHalo: the rank graph needs more than 64 colours for rank-level X "
2238 "(docs/contact_solve_framework.md §1.4); the blocks are too small for the band");
2239 col[r] = c;
2240 maxc = std::max(maxc, c);
2241 }
2242 rankColor_ = col[static_cast<std::size_t>(rank_)];
2243 numRankColors_ = maxc + 1;
2244 }
2247 void buildInterfaceRows(const peclet::core::halo::ParticleHaloTopology<3>::FlatTopo& t, int no) {
2248 std::vector<unsigned char> mark(static_cast<std::size_t>(std::max(no, 1)), 0);
2249 for (const auto i : t.sendIdx)
2250 mark[static_cast<std::size_t>(i)] = 1;
2251 for (const auto i : t.selfIdx)
2252 mark[static_cast<std::size_t>(i)] = 1;
2253 std::vector<int> rows;
2254 for (int i = 0; i < no; ++i)
2255 if (mark[static_cast<std::size_t>(i)])
2256 rows.push_back(i);
2257 nIface_ = static_cast<int>(rows.size());
2258 if (rows.empty())
2259 rows.push_back(0); // never read (n = 0); keeps the list non-empty (an empty one = all rows)
2260 iface_ = peclet::core::toDevice(rows, "peclet::dem::halo::ifaceRows");
2261 ifaceMark_ = peclet::core::toDevice(mark, "peclet::dem::halo::ifaceMark");
2262 }
2267 void buildMRows(Vi k, Vi& rows, int& nRows) {
2268 const int no = numReal_;
2269 if (rows.extent(0) < static_cast<std::size_t>(std::max(no, 1)))
2270 rows = Vi("peclet::dem::halo::mRows", static_cast<std::size_t>(std::max(no, 1)));
2271 nRows = haloBuildMRows(k, ifaceMark_, rows, no);
2272 }
2273 void ensureClampCounter(Particles& P) {
2274 if (P.orphanClampCount.data() == nullptr)
2275 P.orphanClampCount = Kokkos::View<int, CpMem>("peclet::dem::orphanClampCount");
2276 }
2279 void markVelocityReconciled(Particles& P) {
2280 haloMarkOwnerVelocitySeeds(P.velPred, P.angVelPred, P.bodyOrphan, ownerSeedVel_,
2281 ownerSeedAngVel_, ownerSeedOrphan_, nVelRows_, velRows_);
2282 if (orphanShares_)
2283 haloSetOrphanShares(P.bodyOrphan, P.kVel, numReal_ + numGhost_);
2284 markVelocityBaseline(P);
2285 }
2288 void syncVelocitiesM(Particles& P, bool rotation) {
2289 haloVelocityMPackZero(P.velPred, P.angVelPred, P.bodyOrphan, P.bodyOrphanVPeak, baseVel_,
2290 baseAngVel_, baseOrphan_, baseOrphanPeak_, P.aVel, ghostVelInc_,
2291 ghostSlot_, numReal_, numGhost_, ownedVelInc_, velRows_, nVelRows_);
2292 dev_.reverse(ghostVelInc_, ownedVelInc_);
2293 if (orphanShares_) {
2294 if (rotation)
2295 velocityMRound(P, ownedVelStateB_, ghostVelStateB_);
2296 else
2297 velocityMRound(P, ownedVelStateBNR_, ghostVelStateBNR_);
2298 } else if (rotation) {
2299 velocityMRound(P, ownedVelState_, ghostVelState_);
2300 } else {
2301 velocityMRound(P, ownedF3_, ghostF3_);
2302 }
2303 }
2305 template <class S>
2306 void velocityMRound(Particles& P, peclet::core::View<S> owned, peclet::core::View<S> ghost) {
2307 haloVelocityMApply(P.velPred, P.angVelPred, P.bodyOrphan, P.bodyOrphanVPeak, ownerSeedVel_,
2308 ownerSeedAngVel_, ownerSeedOrphan_, P.aVel, P.kVel, ownedVelInc_,
2309 P.orphanClampCount, P.maxConsensus, velRows_, nVelRows_, ifaceMark_, owned,
2310 orphanShares_);
2311 dev_.forward(owned, ghost);
2312 haloVelocityMUnpack(P.velPred, P.angVelPred, P.bodyOrphan, P.bodyOrphanVPeak, ghost, baseVel_,
2313 baseAngVel_, baseOrphan_, baseOrphanPeak_, P.aVel, P.kVel, ghostVelInc_,
2314 P.maxConsensus, ghostSlot_, numReal_, numGhost_, orphanShares_);
2315 }
2317 template <class S>
2318 void positionMRound(Particles& P, peclet::core::View<S> owned, peclet::core::View<S> ghost) {
2319 haloPositionMApply(P.posPred, P.quatPred, ownerSeedPos_, P.aPos, P.kPos, ownedPosInc_,
2320 P.maxConsensus, posRows_, nPosRows_, ifaceMark_, owned);
2321 dev_.forward(owned, ghost);
2322 haloPositionMUnpack(P.posPred, P.quatPred, ghost, shiftDev_, basePos_, P.aPos, P.kPos,
2323 ghostPosInc_, P.maxConsensus, ghostSlot_, numReal_, numGhost_);
2324 }
2325
2326 public:
2330 void syncFrictionCounts(Particles& P) {
2331 if (!exchanges())
2332 return;
2333 haloPackGhostColumn(P.planeFriction, ghostVal_, ghostSlot_, numReal_, numGhost_);
2334 Kokkos::deep_copy(Kokkos::subview(ownedVal_, std::pair<std::size_t, std::size_t>(0, numReal_)),
2335 0.0f);
2336 dev_.reverse(ghostVal_, ownedVal_);
2337 haloAddOwnedColumn(P.planeFriction, ownedVal_, numReal_);
2338 haloPackOwnedColumn(P.planeFriction, ownedVal_, numReal_);
2339 dev_.forward(ownedVal_, ghostVal_);
2340 haloUnpackGhostColumn(P.planeFriction, ghostVal_, ghostSlot_, numReal_, numGhost_);
2341 }
2347 void syncContactCounts(Particles& P) {
2348 if (!exchanges())
2349 return;
2350 haloPackGhostCount(P.constraintCounts, ghostCnt_, ghostSlot_, numReal_, numGhost_);
2351 Kokkos::deep_copy(Kokkos::subview(ownedCnt_, std::pair<std::size_t, std::size_t>(0, numReal_)),
2352 0);
2353 dev_.reverse(ghostCnt_, ownedCnt_);
2354 haloAddPackOwnedCount(P.constraintCounts, ownedCnt_, numReal_);
2355 dev_.forward(ownedCnt_, ghostCnt_);
2356 haloUnpackGhostCount(P.constraintCounts, ghostCnt_, ghostSlot_, numReal_, numGhost_);
2357 }
2358
2359 void selfMapReals(Vi realIndices, int no) {
2360 Kokkos::parallel_for(
2361 "peclet::dem::halo::selfMapReals", Kokkos::RangePolicy<CpExec>(0, no),
2362 KOKKOS_LAMBDA(int i) { realIndices(i) = i; });
2363 }
2364
2365 private:
2366 void allocBuffers(int no, int ng) {
2367 // Exact-sized: ParticleHalo::forward host-stages a deep_copy into the ghost View, so
2368 // its extent must equal numGhost; owned is indexed by sendIdx in [0,numReal).
2369 ownedF3_ = peclet::core::View<F3>("peclet::dem::halo::ownedF3", no);
2370 ghostF3_ = peclet::core::View<F3>("peclet::dem::halo::ghostF3", ng);
2371 ownedF4_ = peclet::core::View<F4>("peclet::dem::halo::ownedF4", no);
2372 ghostF4_ = peclet::core::View<F4>("peclet::dem::halo::ghostF4", ng);
2373 ownedPack_ = peclet::core::View<MpiGatherPack>("peclet::dem::halo::ownedPack", no);
2374 ghostPack_ = peclet::core::View<MpiGatherPack>("peclet::dem::halo::ghostPack", ng);
2375 // Reconciliation buffers: grow-only (core's reverse/forward address [0, numReceived) and the
2376 // self tail by subview/index, and owned rows through sendIdx/selfIdx < no).
2377 const std::size_t g1 = static_cast<std::size_t>(std::max(ng, 1)),
2378 o1 = static_cast<std::size_t>(std::max(no, 1));
2379 if (baseVel_.extent(0) < g1) {
2380 baseVel_ = V3("peclet::dem::halo::baseVel", g1);
2381 baseAngVel_ = V3("peclet::dem::halo::baseAngVel", g1);
2382 basePos_ = V3("peclet::dem::halo::basePos", g1);
2383 baseOrphan_ = Vf("peclet::dem::halo::baseOrphan", g1);
2384 baseOrphanPeak_ = Vf("peclet::dem::halo::baseOrphanPeak", g1);
2385 ghostVelInc_ = peclet::core::View<VelocityIncrement>("peclet::dem::halo::ghostVelInc", g1);
2386 ghostPosInc_ = peclet::core::View<PositionIncrement>("peclet::dem::halo::ghostPosInc", g1);
2387 ghostVal_ = peclet::core::View<float>("peclet::dem::halo::ghostVal", g1);
2388 ghostCnt_ = peclet::core::View<int>("peclet::dem::halo::ghostCnt", g1);
2389 ghostCM_ = peclet::core::View<PositionCountsMask>("peclet::dem::halo::ghostCM", g1);
2390 ghostVelState_ = peclet::core::View<VelocityState>("peclet::dem::halo::ghostVelState", g1);
2391 ghostPosState_ = peclet::core::View<PositionState>("peclet::dem::halo::ghostPosState", g1);
2392 ghostOpenInc_ = peclet::core::View<OpeningIncrement>("peclet::dem::halo::ghostOpenInc", g1);
2393 ghostOpenState_ = peclet::core::View<OpeningState>("peclet::dem::halo::ghostOpenState", g1);
2394 ghostOpenStateNR_ =
2395 peclet::core::View<OpeningStateNoRot>("peclet::dem::halo::ghostOpenStateNR", g1);
2396 ghostVelStateB_ = peclet::core::View<VelocityStateB>("peclet::dem::halo::ghostVelStateB", g1);
2397 ghostVelStateBNR_ =
2398 peclet::core::View<VelocityStateBNoRot>("peclet::dem::halo::ghostVelStateBNR", g1);
2399 }
2400 if (ownedVelInc_.extent(0) < o1) {
2401 ownedVelInc_ = peclet::core::View<VelocityIncrement>("peclet::dem::halo::ownedVelInc", o1);
2402 ownedPosInc_ = peclet::core::View<PositionIncrement>("peclet::dem::halo::ownedPosInc", o1);
2403 ownedVal_ = peclet::core::View<float>("peclet::dem::halo::ownedVal", o1);
2404 ownedCnt_ = peclet::core::View<int>("peclet::dem::halo::ownedCnt", o1);
2405 ownedCM_ = peclet::core::View<PositionCountsMask>("peclet::dem::halo::ownedCM", o1);
2406 ownedVelState_ = peclet::core::View<VelocityState>("peclet::dem::halo::ownedVelState", o1);
2407 ownedPosState_ = peclet::core::View<PositionState>("peclet::dem::halo::ownedPosState", o1);
2408 ownedOpenInc_ = peclet::core::View<OpeningIncrement>("peclet::dem::halo::ownedOpenInc", o1);
2409 ownedOpenState_ = peclet::core::View<OpeningState>("peclet::dem::halo::ownedOpenState", o1);
2410 ownedOpenStateNR_ =
2411 peclet::core::View<OpeningStateNoRot>("peclet::dem::halo::ownedOpenStateNR", o1);
2412 ownedVelStateB_ = peclet::core::View<VelocityStateB>("peclet::dem::halo::ownedVelStateB", o1);
2413 ownedVelStateBNR_ =
2414 peclet::core::View<VelocityStateBNoRot>("peclet::dem::halo::ownedVelStateBNR", o1);
2415 ownerSeedVel_ = V3("peclet::dem::halo::ownerSeedVel", o1);
2416 ownerSeedAngVel_ = V3("peclet::dem::halo::ownerSeedAngVel", o1);
2417 ownerSeedOrphan_ = Vf("peclet::dem::halo::ownerSeedOrphan", o1);
2418 ownerSeedPos_ = V3("peclet::dem::halo::ownerSeedPos", o1);
2419 }
2420 }
2421 // The maps the contact-ownership rule reads (docs/mpi_momentum_conservation.md §2.1), built on
2422 // the host from the flattened topology at every rebuild:
2423 // ghostSource_(s - no) the rank ghost slot s was received from (this rank for a periodic
2424 // self-ghost), indexed by the CANONICAL offset;
2425 // copyOffsets_/copyRanks_ CSR over owned rows: the ranks each owned row is ghosted to
2426 // (cross-rank only, ascending -- sendRanks is ascending).
2427 void buildOwnershipMaps(const peclet::core::halo::ParticleHaloTopology<3>::FlatTopo& t, int no,
2428 int ng, const std::vector<int>& hSlot) {
2429 const std::size_t g1 = static_cast<std::size_t>(std::max(ng, 1));
2430 std::vector<int> src(g1, rank_); // the self tail [numReceived, ng) stays rank_
2431 for (std::size_t k = 0; k < t.recvRanks.size(); ++k)
2432 for (int j = 0; j < t.recvCounts[k]; ++j)
2433 src[static_cast<std::size_t>(hSlot[static_cast<std::size_t>(t.recvOffsets[k]) + j])] =
2434 t.recvRanks[k];
2435 std::vector<int> off(static_cast<std::size_t>(no) + 1, 0);
2436 for (const auto i : t.sendIdx)
2437 ++off[static_cast<std::size_t>(i) + 1];
2438 for (int i = 0; i < no; ++i)
2439 off[static_cast<std::size_t>(i) + 1] += off[static_cast<std::size_t>(i)];
2440 // Integer image of a shift: round(shift / L) on a periodic axis, 0 otherwise (§5.3).
2441 const auto& gsz = dec_.globalSize();
2442 auto imageOf = [&](const peclet::core::Vec<3>& sh) {
2443 int c[3] = {0, 0, 0};
2444 for (int d = 0; d < 3; ++d)
2445 if (map_.periodic[d]) {
2446 const double L = static_cast<double>(gsz[d]) * map_.cellSize[d];
2447 c[d] = static_cast<int>(std::lround(sh[d] / L));
2448 }
2449 return imagePack(c[0], c[1], c[2]);
2450 };
2451 std::vector<int> gimg(g1, imagePack(0, 0, 0));
2452 for (std::size_t j = 0; j < t.shift.size(); ++j)
2453 gimg[static_cast<std::size_t>(hSlot[j])] = imageOf(t.shift[j]);
2454 std::vector<int> ranks(std::max<std::size_t>(t.sendIdx.size(), 1), -1);
2455 std::vector<int> imgs(std::max<std::size_t>(t.sendIdx.size(), 1), imagePack(0, 0, 0));
2456 std::vector<int> cur(off.begin(), off.end() - 1);
2457 for (std::size_t k = 0; k < t.sendRanks.size(); ++k)
2458 for (int j = t.sendOffsets[k]; j < t.sendOffsets[k + 1]; ++j) {
2459 const std::size_t at =
2460 static_cast<std::size_t>(cur[static_cast<std::size_t>(t.sendIdx[j])]++);
2461 ranks[at] = t.sendRanks[k];
2462 imgs[at] = imageOf(t.sendShift[static_cast<std::size_t>(j)]);
2463 }
2464 // Self-image CSR over owned rows: the self tail [numReceived, ng) of the topology.
2465 std::vector<int> soff(static_cast<std::size_t>(no) + 1, 0);
2466 for (const auto i : t.selfIdx)
2467 ++soff[static_cast<std::size_t>(i) + 1];
2468 for (int i = 0; i < no; ++i)
2469 soff[static_cast<std::size_t>(i) + 1] += soff[static_cast<std::size_t>(i)];
2470 std::vector<int> simg(std::max<std::size_t>(t.selfIdx.size(), 1), imagePack(0, 0, 0));
2471 {
2472 std::vector<int> scur(soff.begin(), soff.end() - 1);
2473 for (std::size_t j = 0; j < t.selfIdx.size(); ++j)
2474 simg[static_cast<std::size_t>(scur[static_cast<std::size_t>(t.selfIdx[j])]++)] =
2475 imageOf(t.shift[static_cast<std::size_t>(t.numReceived) + j]);
2476 }
2477 selfOffsets_ = peclet::core::toDevice(soff, "peclet::dem::halo::selfOffsets");
2478 selfImage_ = peclet::core::toDevice(simg, "peclet::dem::halo::selfImage");
2479 ghostSource_ = peclet::core::toDevice(src, "peclet::dem::halo::ghostSource");
2480 ghostImage_ = peclet::core::toDevice(gimg, "peclet::dem::halo::ghostImage");
2481 copyOffsets_ = peclet::core::toDevice(off, "peclet::dem::halo::copyOffsets");
2482 copyRanks_ = peclet::core::toDevice(ranks, "peclet::dem::halo::copyRanks");
2483 copyImage_ = peclet::core::toDevice(imgs, "peclet::dem::halo::copyImage");
2484 }
2485 // Max Euclidean displacement of any owned particle since the last topology build (device reduce +
2486 // one scalar read-back) — the Verlet-skin reuse criterion.
2487 // public: nvcc forbids an extended __host__ __device__ (KOKKOS_LAMBDA) lambda inside a private
2488 // method.
2489 public:
2490 float maxOwnedDisplacement(const V3& pos, int no) const {
2491 // A rank that owns nothing has moved nothing. (It used to report "moved", and under the
2492 // global rebuild vote one empty rank then rebuilt every rank's halo at every gather.) An owned
2493 // count change forces the rebuild on its own, before this is asked.
2494 if (no <= 0)
2495 return 0.0f;
2496 if (refPos_.extent(0) < static_cast<std::size_t>(no))
2497 return 1e30f;
2498 float md = 0.0f;
2499 V3 p = pos, r = refPos_;
2500 Kokkos::parallel_reduce(
2501 "peclet::dem::halo::maxdisp", Kokkos::RangePolicy<CpExec>(0, no),
2502 KOKKOS_LAMBDA(const int i, float& m) {
2503 const float dx = p(i, 0) - r(i, 0), dy = p(i, 1) - r(i, 1), dz = p(i, 2) - r(i, 2);
2504 const float d = Kokkos::sqrt(dx * dx + dy * dy + dz * dz);
2505 if (d > m)
2506 m = d;
2507 },
2508 Kokkos::Max<float>(md));
2509 return md;
2510 }
2511
2512 private:
2513 // Canonical ghost placement: topology ghost g -> slot offset in [0, ng). The received blocks go
2514 // in ascending source rank (core numbers them in message-arrival order); the local periodic
2515 // self-ghosts [numReceived, ng) keep their place after them.
2516 std::vector<int> uploadGhostSlots(const peclet::core::halo::ParticleHaloTopology<3>::FlatTopo& t,
2517 int ng) {
2518 std::vector<std::size_t> blocks(t.recvRanks.size());
2519 for (std::size_t k = 0; k < blocks.size(); ++k)
2520 blocks[k] = k;
2521 std::sort(blocks.begin(), blocks.end(),
2522 [&](std::size_t a, std::size_t b) { return t.recvRanks[a] < t.recvRanks[b]; });
2523 if (ghostSlot_.extent(0) < static_cast<std::size_t>(std::max(ng, 1)))
2524 ghostSlot_ = Vi("peclet::dem::halo::ghostSlot", static_cast<std::size_t>(std::max(ng, 1)));
2525 auto h = Kokkos::create_mirror_view(ghostSlot_);
2526 int next = 0;
2527 for (std::size_t k : blocks)
2528 for (int j = 0; j < t.recvCounts[k]; ++j)
2529 h(static_cast<int>(t.recvOffsets[k]) + j) = next++;
2530 for (int g = next; g < ng; ++g)
2531 h(g) = g;
2532 Kokkos::deep_copy(ghostSlot_, h);
2533 std::vector<int> hs(static_cast<std::size_t>(std::max(ng, 0)));
2534 for (int g = 0; g < ng; ++g)
2535 hs[static_cast<std::size_t>(g)] = h(g);
2536 return hs;
2537 }
2538 // Canonical owned order after a migration: core keeps this rank's stayers first (in their old
2539 // order) and appends the lastReceived() arrivals message by message, in arrival order. Stably
2540 // re-order the arrivals by their sending rank; within one sender the order is the sender's.
2541 void orderArrivalsBySource(std::vector<peclet::core::Vec<3>>& pos, std::vector<char>& payload,
2542 std::size_t newN) {
2543 const std::size_t nArr = mig_.lastReceived();
2544 if (nArr < 2 || nArr > newN)
2545 return;
2546 const std::size_t first = newN - nArr;
2547 std::vector<int> src(nArr);
2548 for (std::size_t k = 0; k < nArr; ++k)
2549 std::memcpy(&src[k],
2550 &payload[(first + k) * sizeof(MigratePack) + offsetof(MigratePack, srcRank)],
2551 sizeof(int));
2552 if (std::is_sorted(src.begin(), src.end()))
2553 return;
2554 std::vector<std::size_t> perm(nArr);
2555 for (std::size_t k = 0; k < nArr; ++k)
2556 perm[k] = k;
2557 std::stable_sort(perm.begin(), perm.end(),
2558 [&](std::size_t a, std::size_t b) { return src[a] < src[b]; });
2559 std::vector<peclet::core::Vec<3>> p2(nArr);
2560 std::vector<char> b2(nArr * sizeof(MigratePack));
2561 for (std::size_t k = 0; k < nArr; ++k) {
2562 p2[k] = pos[first + perm[k]];
2563 std::memcpy(&b2[k * sizeof(MigratePack)], &payload[(first + perm[k]) * sizeof(MigratePack)],
2564 sizeof(MigratePack));
2565 }
2566 std::copy(p2.begin(), p2.end(), pos.begin() + static_cast<std::ptrdiff_t>(first));
2567 std::memcpy(&payload[first * sizeof(MigratePack)], b2.data(), b2.size());
2568 }
2569
2570 void uploadShift(const peclet::core::halo::ParticleHaloTopology<3>::FlatTopo& t) {
2571 std::vector<F3> hs(t.shift.size());
2572 for (std::size_t i = 0; i < t.shift.size(); ++i)
2573 hs[i] = F3{static_cast<float>(t.shift[i][0]), static_cast<float>(t.shift[i][1]),
2574 static_cast<float>(t.shift[i][2])};
2575 shiftDev_ = peclet::core::toDevice(hs, "peclet::dem::halo::shift");
2576 }
2577
2578 bool inited_ = false;
2579 int rank_ = 0, numReal_ = 0, numGhost_ = 0;
2580 int numSend_ = 0; // owned copies this rank sends per forward (cross-rank; set at each rebuild)
2581 // Verlet-skin reuse state (D2): skin width, the build-time owned positions, and the cache-valid
2582 // flags.
2583 float verletSkin_ = 0.0f;
2584 bool haveTopo_ = false;
2585 int lastNumReal_ = -1;
2586 double lastBand_ = -1.0; // rcut + skin of the cached topology
2587 long nRebuild_ = 0, nGather_ = 0;
2588 V3 refPos_;
2589 MPI_Comm comm_ = MPI_COMM_NULL;
2590 peclet::core::decomp::BlockDecomposer<3> dec_;
2591 peclet::core::halo::DomainMap<3> map_; // physical<->cell mapping (for migrateTo)
2592 peclet::core::halo::ParticleMigrator<3> mig_;
2593 peclet::core::halo::ParticleHaloTopology<3> halo_;
2594 peclet::core::halo::ParticleHalo<3> dev_;
2595 peclet::core::View<F3> ownedF3_, ghostF3_, shiftDev_;
2596 Vi ghostSlot_; // topology ghost index -> canonical ghost slot offset (see uploadGhostSlots)
2597 peclet::core::View<F4> ownedF4_, ghostF4_;
2598 peclet::core::View<MpiGatherPack> ownedPack_, ghostPack_;
2599 // Reconciliation state (§2.3): per-ghost baselines in TOPOLOGY ghost order, the reverse payloads
2600 // and the count-column buffers -- all grow-only.
2601 V3 baseVel_, baseAngVel_, basePos_;
2602 Vf baseOrphan_, baseOrphanPeak_;
2603 peclet::core::View<VelocityIncrement> ghostVelInc_, ownedVelInc_;
2604 peclet::core::View<PositionIncrement> ghostPosInc_, ownedPosInc_;
2605 peclet::core::View<float> ghostVal_, ownedVal_;
2606 peclet::core::View<int> ghostCnt_, ownedCnt_;
2607 peclet::core::View<PositionCountsMask> ghostCM_, ownedCM_;
2608 // Rank-level X (§1.4): this rank's colour and the number of colours, for the band they were
2609 // computed with (-1: stale; reset whenever the decomposition changes).
2610 int rankColor_ = 0, numRankColors_ = 1;
2611 double colorBand_ = -1.0;
2612 unsigned long long colorFingerprint_ = 0ull;
2613 peclet::core::View<VelocityState> ghostVelState_, ownedVelState_; // fused forwards (rotation)
2614 peclet::core::View<PositionState> ghostPosState_, ownedPosState_;
2615 // Ownership maps (§2.1; see buildOwnershipMaps).
2616 Kokkos::View<int*, CpMem> ghostSource_, copyOffsets_, copyRanks_, ghostImage_, copyImage_,
2617 selfOffsets_, selfImage_;
2618 // Rank-level M (docs/contact_solve_framework.md §13.3): the opening / Poisson forward payloads,
2619 // the owner seeds [0, numReal) (grow-only), this substep's mode flags (reset by gather), and
2620 // the velocity slot map (§6.2).
2621 peclet::core::View<OpeningIncrement> ghostOpenInc_, ownedOpenInc_;
2622 peclet::core::View<OpeningState> ghostOpenState_, ownedOpenState_;
2623 peclet::core::View<OpeningStateNoRot> ghostOpenStateNR_, ownedOpenStateNR_;
2624 peclet::core::View<VelocityStateB> ghostVelStateB_, ownedVelStateB_;
2625 peclet::core::View<VelocityStateBNoRot> ghostVelStateBNR_, ownedVelStateBNR_;
2626 V3 ownerSeedVel_, ownerSeedAngVel_, ownerSeedPos_;
2627 Vf ownerSeedOrphan_;
2628 bool velM_ = false, posM_ = false, orphanShares_ = false;
2629 bool canonStale_ = true;
2630 int numReceived_ = 0; // cross-rank ghosts of the current topology (FlatTopo::numReceived)
2631 Vi iface_; // interface rows (buildInterfaceRows); nIface_ of them
2632 Kokkos::View<unsigned char*, CpMem> ifaceMark_;
2633 int nIface_ = 0;
2634 Vi velRows_, posRows_; // the phases' M rows (buildMRows)
2635 int nVelRows_ = 0, nPosRows_ = 0;
2636 std::vector<int> selfIdx_; // owned row of each local self ghost (FlatTopo::selfIdx)
2637 Kokkos::View<int*, CpMem> ghostCanon_;
2638};
2639
2640} // namespace peclet::dem
2641
2642#endif // PECLET_DEM_MPI
2643#endif // PECLET_DEM_MPI_HALO_HPP
dem — portable POD types + math + analytic SDFs shared by the Kokkos kernel ports.
Kokkos::View< float *, CpMem > Vf
Kokkos::View< int *, CpMem > Vi
Kokkos::View< float *[3], CpMem > V3
CpExec::memory_space CpMem
Kokkos::View< float *[4], CpMem > V4
dem — portable (Kokkos) particle SoA container: the storage the dem flip pivots on.
int MPI_Allreduce(const void *a, void *b, int c, MPI_Datatype d, MPI_Op e, MPI_Comm f)
Definition pmpi.cpp:6