flow 0.4.0
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
mac_cutcell_mg.hpp
Go to the documentation of this file.
1
14#ifndef PECLET_FLOW_MAC_CUTCELL_MG_HPP
15#define PECLET_FLOW_MAC_CUTCELL_MG_HPP
16
17#include <chrono>
18#include <cmath>
19#include <cstdlib>
20#include <limits>
21#include <Kokkos_Core.hpp>
22#include <memory>
23#include <string>
24#include <vector>
25
26#include "ghost_projection.hpp" // GpOverlay + gpApplyDelta (ghost-projection BiCGStab matvec)
27#include "star_elimination.hpp" // StarOverlay + starApplyDelta (mode-B fluid-only PCG matvec)
28#include "mac_bc.hpp"
29#include "mac_pressure.hpp"
30#include "peclet/core/solver/graph_amg.hpp" // decomposition-agnostic algebraic bottom solve
31
32// Multi-rank (MPI) path is opt-in: the single-GPU module never links MPI, so all distributed code
33// is gated (mirrors the CUDA PECLET_FLOW_BUILD_MPI gating). When PECLET_FLOW_MPI is off, CutcellMG
34// is byte-identical to before.
35#ifdef PECLET_FLOW_MPI
36#include <memory>
37
38#include "peclet/core/decomp/block_decomposer.hpp"
39#include "peclet/core/decomp/grid_redistribute.hpp"
40#include "peclet/core/halo/grid_halo.hpp"
41#include "peclet/core/halo/grid_halo_topology.hpp"
42#endif
43
44namespace peclet::flow {
45
46#ifdef PECLET_FLOW_MPI
47using peclet::core::halo::GridHalo;
48using peclet::core::halo::GridHaloTopology;
49#endif
50
51using MReal = float; // operator storage = CUDA mreal
52using FPV = Kokkos::View<MReal*, CCMem>;
53using FPC = Kokkos::View<const MReal*, CCMem>;
54
55// coarsen staggered face openness: each coarse face = average of the ratio_b*ratio_c fine sub-faces
56// it spans (mg_coarsen_open_avg_k port). gc/gf: coarse/fine block ghost widths (they can differ —
57// CA-eligible coarse levels carry g=2).
59 CCConst ozf, C3 cext, C3 fext, int gc, int gf, C3 cinner, C3 ratio) {
61 using MD = Kokkos::MDRangePolicy<CCExec, Kokkos::Rank<3>>;
62 Kokkos::parallel_for(
63 "peclet::flow::coarsen_open", MD(space, {0, 0, 0}, {cinner.x, cinner.y, cinner.z}),
64 KOKKOS_LAMBDA(int icx, int icy, int icz) {
65 const int rx = ratio.x, ry = ratio.y, rz = ratio.z;
66 const int fx0 = rx * icx + gf, fy0 = ry * icy + gf, fz0 = rz * icz + gf;
67 const long fsy = fext.x, fsz = (long)fext.x * fext.y;
68 auto F = [&](CCConst T, int x, int y, int z) {
69 return T((long)x + (long)y * fsy + (long)z * fsz);
70 };
71 double sx = 0, sy = 0, sz = 0;
72 for (int a = 0; a < ry; ++a)
73 for (int b = 0; b < rz; ++b)
74 sx += F(oxf, fx0, fy0 + a, fz0 + b);
75 for (int a = 0; a < rx; ++a)
76 for (int b = 0; b < rz; ++b)
77 sy += F(oyf, fx0 + a, fy0, fz0 + b);
78 for (int a = 0; a < rx; ++a)
79 for (int b = 0; b < ry; ++b)
80 sz += F(ozf, fx0 + a, fy0 + b, fz0);
81 const long ci =
82 (long)(icx + gc) + (long)(icy + gc) * cext.x + (long)(icz + gc) * (long)cext.x * cext.y;
83 oxc(ci) = sx / (double)(ry * rz);
84 oyc(ci) = sy / (double)(rx * rz);
85 ozc(ci) = sz / (double)(rx * ry);
86 });
87}
88
89// residual r = b - A x for the float operator (mg_residual_var_k port).
90inline void residualCutcell(CCField r, CCConst x, CCConst b, FPC AC, FPC AW, FPC AE, FPC AS, FPC AN,
91 FPC AB, FPC AT, C3 e, int g) {
92 ccFor3(
93 "peclet::flow::cc_residual", C3{g, g, g}, C3{e.x - g, e.y - g, e.z - g},
94 KOKKOS_LAMBDA(int lx, int ly, int lz) {
95 const long sx = 1, sy = e.x, sz = (long)e.x * e.y;
96 const long i = (long)lx + (long)ly * sy + (long)lz * sz;
97 const double Ax = (double)AC(i) * x(i) + (double)AE(i) * x(i + sx) +
98 (double)AW(i) * x(i - sx) + (double)AN(i) * x(i + sy) +
99 (double)AS(i) * x(i - sy) + (double)AT(i) * x(i + sz) +
100 (double)AB(i) * x(i - sz);
101 r(i) = b(i) - Ax;
102 });
103}
104
105// Residual over a box [rlo,rhi) minus a skip box [slo,shi) — the halo-overlapped form of
106// residualCutcell (interior first while the exchange is in flight, then the boundary shell).
107inline void residualCutcellBox(CCField r, CCConst x, CCConst b, FPC AC, FPC AW, FPC AE, FPC AS,
108 FPC AN, FPC AB, FPC AT, C3 e, C3 rlo, C3 rhi, C3 slo, C3 shi) {
109 if (rhi.x <= rlo.x || rhi.y <= rlo.y || rhi.z <= rlo.z)
110 return;
111 ccFor3(
112 "peclet::flow::cc_residual_box", rlo, rhi, KOKKOS_LAMBDA(int lx, int ly, int lz) {
113 if (lx >= slo.x && lx < shi.x && ly >= slo.y && ly < shi.y && lz >= slo.z && lz < shi.z)
114 return; // inside the skip box (already done by the interior pass)
115 const long sx = 1, sy = e.x, sz = (long)e.x * e.y;
116 const long i = (long)lx + (long)ly * sy + (long)lz * sz;
117 const double Ax = (double)AC(i) * x(i) + (double)AE(i) * x(i + sx) +
118 (double)AW(i) * x(i - sx) + (double)AN(i) * x(i + sy) +
119 (double)AS(i) * x(i - sy) + (double)AT(i) * x(i + sz) +
120 (double)AB(i) * x(i - sz);
121 r(i) = b(i) - Ax;
122 });
123}
124
125// average restriction (coarse = mean of ratio^3 fine children; mg_restrict_k) + trilinear
126// prolongation (added to fine; mg_prolong_k). Both over inner cells. gc/gf: coarse/fine block
127// ghost widths (CA-eligible coarse levels carry g=2, so they can differ across one transfer).
128inline void restrictAvg(CCField coarse, CCConst fine, C3 cext, C3 fext, int gc, int gf, C3 cinner,
129 C3 ratio) {
130 ccFor3(
131 "peclet::flow::restrict", C3{0, 0, 0}, C3{cinner.x, cinner.y, cinner.z},
132 KOKKOS_LAMBDA(int icx, int icy, int icz) {
133 const long fsy = fext.x, fsz = (long)fext.x * fext.y;
134 double s = 0;
135 for (int dz = 0; dz < ratio.z; ++dz)
136 for (int dy = 0; dy < ratio.y; ++dy)
137 for (int dx = 0; dx < ratio.x; ++dx) {
138 const int fx = ratio.x * icx + dx + gf, fy = ratio.y * icy + dy + gf,
139 fz = ratio.z * icz + dz + gf;
140 s += fine((long)fx + (long)fy * fsy + (long)fz * fsz);
141 }
142 const long ci =
143 (long)(icx + gc) + (long)(icy + gc) * cext.x + (long)(icz + gc) * (long)cext.x * cext.y;
144 coarse(ci) = s / (double)(ratio.x * ratio.y * ratio.z);
145 });
146}
147inline void prolongAdd(CCField fine, CCConst coarse, C3 fext, C3 cext, int gf, int gc, C3 finner,
148 C3 ratio) {
149 ccFor3(
150 "peclet::flow::prolong", C3{0, 0, 0}, C3{finner.x, finner.y, finner.z},
151 KOKKOS_LAMBDA(int ifx, int ify, int ifz) {
152 // coarse sample coord: coarsened axis (ratio 2) -> 0.5*ifine - 0.25 + gc; kept axis (ratio
153 // 1) -> ifine+gc
154 const double cx = (ratio.x == 2) ? 0.5 * ifx - 0.25 + gc : ifx + gc;
155 const double cy = (ratio.y == 2) ? 0.5 * ify - 0.25 + gc : ify + gc;
156 const double cz = (ratio.z == 2) ? 0.5 * ifz - 0.25 + gc : ifz + gc;
157 const double fxw = Kokkos::floor(cx), fyw = Kokkos::floor(cy), fzw = Kokkos::floor(cz);
158 const double wx = cx - fxw, wy = cy - fyw, wz = cz - fzw;
159 const int x0 = (int)fxw, y0 = (int)fyw, z0 = (int)fzw;
160 const long sy = cext.x, sz = (long)cext.x * cext.y;
161 auto C = [&](int xx, int yy, int zz) {
162 return coarse((long)xx + (long)yy * sy + (long)zz * sz);
163 };
164 const double c00 = C(x0, y0, z0) * (1 - wx) + C(x0 + 1, y0, z0) * wx;
165 const double c10 = C(x0, y0 + 1, z0) * (1 - wx) + C(x0 + 1, y0 + 1, z0) * wx;
166 const double c01 = C(x0, y0, z0 + 1) * (1 - wx) + C(x0 + 1, y0, z0 + 1) * wx;
167 const double c11 = C(x0, y0 + 1, z0 + 1) * (1 - wx) + C(x0 + 1, y0 + 1, z0 + 1) * wx;
168 const double c0 = c00 * (1 - wy) + c10 * wy, c1 = c01 * (1 - wy) + c11 * wy;
169 const long fi =
170 (long)(ifx + gf) + (long)(ify + gf) * fext.x + (long)(ifz + gf) * (long)fext.x * fext.y;
171 fine(fi) += c0 * (1 - wz) + c1 * wz;
172 });
173}
174
175// Env-gated MG diagnostics (host printf only, off unless PECLET_FLOW_MG_DEBUG is set):
176// 1 = level table (per-level global/local dims + coarsening ratio) at build time
177// 2 = + PCG/V-cycle residual history for the first PECLET_FLOW_MG_DEBUG_SOLVES (default 3) solves
178// Used to diagnose decomposition-dependent iteration counts; no effect on the solve itself.
179inline int mgDebugLevel() {
180 static const int lv = [] {
181 const char* e = std::getenv("PECLET_FLOW_MG_DEBUG");
182 return e ? std::atoi(e) : 0;
183 }();
184 return lv;
185}
186inline int mgDebugSolves() {
187 static const int n = [] {
188 const char* e = std::getenv("PECLET_FLOW_MG_DEBUG_SOLVES");
189 return e ? std::atoi(e) : 3;
190 }();
191 return n;
192}
193
194// Communication-avoiding smoothing (PECLET_FLOW_CA): exchange a 2-deep ghost layer once per
195// red-black PAIR instead of 1-deep before every colour, redundantly re-smoothing the 1-deep ghost
196// ring of the first colour so the second colour reads exactly the values a per-colour exchange
197// would have delivered — bit-identical, at half the halo events. Consumed by CutcellMG's coarse
198// levels and by the momentum RB-GS in flow_ibm.hpp. PECLET_FLOW_CA values: unset / "1" = both
199// (default), "0" = off, "mom" = momentum sweeps only, "mg" = pressure-MG coarse levels only —
200// the split exists to ATTRIBUTE a measured regression to one subsystem without a rebuild.
201enum : int { kCaMomentum = 1, kCaMg = 2 };
202inline int caSmoothingMode() {
203 static const int v = [] {
204 const char* e = std::getenv("PECLET_FLOW_CA");
205 if (!e)
206 return kCaMomentum | kCaMg;
207 const std::string s(e);
208 if (s == "mom" || s == "momentum")
209 return (int)kCaMomentum;
210 if (s == "mg")
211 return (int)kCaMg;
212 return std::atoi(e) != 0 ? (kCaMomentum | kCaMg) : 0;
213 }();
214 return v;
215}
216
218 public:
219 struct Level {
220 C3 ext, inner, ratio{2, 2, 2}, cfac{1, 1, 1};
221 C3 og{0, 0, 0}; // block inner origin in GLOBAL cells; {0,0,0} single-rank
222 std::size_t n = 0;
223 // Ghost width of this level's block (1 default; 2 on distributed coarse levels eligible for
224 // communication-avoiding smoothing — see initMpi). Single-rank always 1 (byte-identical).
225 int g = 1;
226 bool caOk = false; // width-2 topology built and every rank's block extent >= 4
228 FPV AC, AW, AE, AS, AN, AB, AT;
229#ifdef PECLET_FLOW_MPI
230 std::shared_ptr<GridHaloTopology<3>> halo; // per-level topology (decomposed)
231 std::shared_ptr<GridHalo<double>> dev; // per-level ghost exchange
232#endif
233 };
234 static constexpr int G = 1; // level-0 / single-rank ghost width (the flow_ibm g=1 bridge)
235 // The red-black parity origin for a level's smoother: the parity convention is the single-rank
236 // g=1 one (parity of og+local index INCLUDING a 1-cell ghost offset), so a level with g=2 must
237 // shift its origin by g-1 per axis or its colours come out swapped against the g=1 reference
238 // (3 axes -> parity flips). og itself stays the true global inner origin (buildAmg needs it).
239 static C3 parityOg(const Level& lv) {
240 return C3{lv.og.x - lv.g + 1, lv.og.y - lv.g + 1, lv.og.z - lv.g + 1};
241 }
242
243 // build the periodic level hierarchy: per axis, halve inner while even and >=2 (uniform when
244 // cubic), capped at nLevels (mirrors DistributedPoissonMG::init uniform path).
245 void init(int nx, int ny, int nz, int nLevels) {
246 lv_.clear();
247 amg_.reset();
248 gnxF_ = nx;
249 gnyF_ = ny;
250 gnzF_ = nz;
251 C3 inner{nx, ny, nz}, cf{1, 1, 1};
252 for (int L = 0; L < nLevels; ++L) {
253 Level v;
254 v.inner = inner;
255 v.ext = C3{inner.x + 2 * G, inner.y + 2 * G, inner.z + 2 * G};
256 v.cfac = cf;
257 v.n = (std::size_t)v.ext.x * v.ext.y * v.ext.z;
258 auto can = [&](int d) { return (d % 2 == 0) && (d / 2 >= 2); };
259 C3 next = inner;
260 C3 ratio{1, 1, 1};
261 if (L + 1 < nLevels) {
262 if (can(inner.x)) {
263 ratio.x = 2;
264 next.x = inner.x / 2;
265 }
266 if (can(inner.y)) {
267 ratio.y = 2;
268 next.y = inner.y / 2;
269 }
270 if (can(inner.z)) {
271 ratio.z = 2;
272 next.z = inner.z / 2;
273 }
274 }
275 v.ratio = ratio;
276 v.x = CCField("mg_x", v.n);
277 v.rhs = CCField("mg_rhs", v.n);
278 v.res = CCField("mg_res", v.n);
279 v.ox = CCField("mg_ox", v.n);
280 v.oy = CCField("mg_oy", v.n);
281 v.oz = CCField("mg_oz", v.n);
282 for (FPV* p : {&v.AC, &v.AW, &v.AE, &v.AS, &v.AN, &v.AB, &v.AT})
283 *p = FPV("mg_A", v.n);
284 lv_.push_back(v);
285 if (next.x == inner.x && next.y == inner.y && next.z == inner.z)
286 break; // nothing coarsens
287 inner = next;
288 cf = C3{cf.x * ratio.x, cf.y * ratio.y, cf.z * ratio.z};
289 }
290 if (mgDebugLevel()) {
291 printf("[mg] init %dx%dx%d single-rank -> %d levels (requested %d)\n", nx, ny, nz,
292 (int)lv_.size(), nLevels);
293 for (int L = 0; L < (int)lv_.size(); ++L)
294 printf("[mg] L%d dims %4dx%4dx%4d ratio(%d,%d,%d)\n", L, lv_[L].inner.x, lv_[L].inner.y,
295 lv_[L].inner.z, lv_[L].ratio.x, lv_[L].ratio.y, lv_[L].ratio.z);
296 fflush(stdout);
297 }
298 }
299#ifdef PECLET_FLOW_MPI
300 // Multi-rank hierarchy: coarsen the GLOBAL grid 2:1 per level; each level gets its own core halo
301 // over a BlockDecomposer of that level's grid (the ORB decomposition coarsens cleanly so
302 // restrict/prolong stay local). Sets the distributed flag -> fill() exchanges, the reductions
303 // Allreduce, the smoother uses the block's global-origin parity. Single-rank (size 1) reproduces
304 // init()'s field exactly.
305 // dec0: OPTIONAL shared level-0 decomposition (load-balance / CFD-DEM co-decomposition). When
306 // given, level 0 uses it so the MG's level-0 block matches the caller's (possibly weighted)
307 // block; the coarse levels keep the equal-weight ORB of the coarsened grid. For a weighted dec0
308 // the coarse-level transfer is only clean when nLevels==1 (pure RB-GS) — use that (or the
309 // decomposition-agnostic GraphAMG) for a weighted co-decomposition. nullptr => equal-weight
310 // everywhere (the original behaviour, byte-identical).
311 // Per-axis split alignment that makes an ORB safely coarsenable by this MG: align[k] =
312 // 2^(number of times axis k can coarsen, until it turns odd) — the NATURAL MAXIMUM, independent of
313 // the actual nLevels (over-aligning is harmless: coarsened() still divides cleanly at every real
314 // level). Depends only on the global grid, so the solver's dec_, the mpi_block() sizing, and this
315 // MG all compute the SAME value without threading nLevels. The solver builds its shared
316 // decomposition with this alignment so initMpi derives nested coarse levels via coarsened().
317 static peclet::core::IVec<3> coarsenAlignment(int gnx, int gny, int gnz) {
318 auto can = [](int d) { return (d % 2 == 0) && (d / 2 >= 2); };
319 C3 gs{gnx, gny, gnz};
320 peclet::core::IVec<3> a{1, 1, 1};
321 for (bool any = true; any;) {
322 any = false;
323 if (can(gs.x)) { a[0] *= 2; gs.x /= 2; any = true; }
324 if (can(gs.y)) { a[1] *= 2; gs.y /= 2; any = true; }
325 if (can(gs.z)) { a[2] *= 2; gs.z /= 2; any = true; }
326 }
327 // Cap at 2^(default nLevels - 1): all the 5-level hierarchy needs. The UNCAPPED natural-max
328 // over-constrains the ORB on power-of-two-rich grids (e.g. 192^3 -> align 64): the split snap
329 // then rounds a balanced 96|96 to 128|64 (cascading 2:1 load imbalance), and once sub-boxes
330 // drop under 2*align the snap is skipped -> unaligned splits -> the even-coarsening gate
331 // collapses the MG depth (measured: 192^3 np=24 pure-MPI, 27 pressure iters/step vs 9, 3.4x
332 // step time). With the cap the same case decomposes perfectly evenly and keeps 5 nested
333 // levels; axes whose natural alignment is smaller are unchanged, deeper hierarchies degrade
334 // through the existing evenBlocks gate exactly as before.
335 for (int k = 0; k < 3; ++k)
336 if (a[k] > 16)
337 a[k] = 16;
338 return a;
339 }
340
341 // ---- coarse-first ("decompose coarse, refine upward") decomposition ---------------------------
342 // Requested hierarchy depth for the LEVEL-0 DECOMPOSITION: 0 (default) = the legacy aligned-ORB
343 // route above; L >= 2 = build the ORB on the grid coarsened L-1 times and refine the partition
344 // upward, which guarantees L nested levels and balances on the coarse grid instead of snapping
345 // fine splits afterwards. Read once from PECLET_FLOW_DECOMP_LEVELS, overridable programmatically.
346 // MUST be set before the decomposition is built (i.e. before mpi_block()/init_mpi), because all
347 // three call sites — mpi_block(), IbmSolver::initMpi and this class — derive the SAME partition
348 // from it and would otherwise disagree about the block layout.
349 static int& decompositionLevelsRef() {
350 static int v = [] {
351 const char* e = std::getenv("PECLET_FLOW_DECOMP_LEVELS");
352 return e ? std::atoi(e) : 0;
353 }();
354 return v;
355 }
356 static int decompositionLevels() { return decompositionLevelsRef(); }
358
359 // Per-axis coarsening factor a depth-`levels` hierarchy will actually apply: 2^(levels-1), bounded
360 // by that axis's factors of two (an odd axis never coarsens, so its factor stays 1).
361 static peclet::core::IVec<3> refineFactor(int gnx, int gny, int gnz, int levels) {
362 auto can = [](int d) { return (d % 2 == 0) && (d / 2 >= 2); };
363 C3 gs{gnx, gny, gnz};
364 peclet::core::IVec<3> r{1, 1, 1};
365 for (int L = 1; L < levels; ++L) {
366 if (can(gs.x)) { r[0] *= 2; gs.x /= 2; }
367 if (can(gs.y)) { r[1] *= 2; gs.y /= 2; }
368 if (can(gs.z)) { r[2] *= 2; gs.z /= 2; }
369 }
370 return r;
371 }
372
373 // THE shared level-0 decomposition. Every call site must go through this so the solver's block,
374 // mpi_block()'s sizing and the MG's level 0 cannot drift apart.
375 static peclet::core::decomp::BlockDecomposer<3> decomposition(std::size_t numBlocks, int gnx,
376 int gny, int gnz) {
377 const int levels = decompositionLevels();
378 if (levels < 2)
379 return peclet::core::decomp::BlockDecomposer<3>(numBlocks, peclet::core::IVec<3>{gnx, gny, gnz},
381 // Depth and load balance pull against each other: each extra level doubles the quantum on every
382 // axis that still coarsens, and a partition built on the coarse grid can only place a split on a
383 // coarse-cell boundary. So rather than guess a granularity, BUILD each candidate and measure its
384 // imbalance: take the deepest one that stays within budget, else keep the legacy aligned ORB.
385 // The whole search is a pure function of (numBlocks, grid, levels) — every rank computes the
386 // same answer without communicating.
387 const double maxImbalance = [] {
388 const char* e = std::getenv("PECLET_FLOW_DECOMP_MAX_IMBALANCE");
389 const double v = e ? std::atof(e) : 1.05;
390 return v > 1.0 ? v : 1.05;
391 }();
392 auto imbalanceOf = [](const peclet::core::decomp::BlockDecomposer<3>& d) {
393 std::size_t hi = 0, lo = std::numeric_limits<std::size_t>::max();
394 for (const auto& s : d.sizes()) {
395 const std::size_t n = static_cast<std::size_t>(s[0]) * static_cast<std::size_t>(s[1]) *
396 static_cast<std::size_t>(s[2]);
397 hi = n > hi ? n : hi;
398 lo = n < lo ? n : lo;
399 }
400 return lo ? static_cast<double>(hi) / static_cast<double>(lo)
402 };
403 for (int L = levels; L >= 2; --L) {
404 const peclet::core::IVec<3> r = refineFactor(gnx, gny, gnz, L);
405 if (r[0] == 1 && r[1] == 1 && r[2] == 1)
406 break; // nothing coarsens on any axis — the aligned ORB is all there is
407 const std::size_t cells = static_cast<std::size_t>(gnx / r[0]) *
408 static_cast<std::size_t>(gny / r[1]) *
409 static_cast<std::size_t>(gnz / r[2]);
410 if (cells < numBlocks)
411 continue; // a coarse grid thinner than the rank count would hand someone an empty block
412 // Decompose the COARSE grid — telling the ORB each coarse cell's true extent, so it picks the
413 // same split axes the fine grid would — then refine the partition upward. Blocks come out as
414 // exact multiples of r, so every level nests for the full depth.
415 peclet::core::decomp::BlockDecomposer<3> coarse;
416 coarse.init(numBlocks, peclet::core::IVec<3>{gnx / r[0], gny / r[1], gnz / r[2]},
417 peclet::core::IVec<3>{1, 1, 1}, r);
418 peclet::core::decomp::BlockDecomposer<3> fine = coarse.refined(r);
419 if (imbalanceOf(fine) <= maxImbalance) {
420 if (mgDebugLevel())
421 printf("[mg] decomposition: coarse-first depth %d (refine %dx%dx%d, imbalance %.3f)\n", L,
422 r[0], r[1], r[2], imbalanceOf(fine));
423 return fine;
424 }
425 }
426 return peclet::core::decomp::BlockDecomposer<3>(numBlocks, peclet::core::IVec<3>{gnx, gny, gnz},
428 }
429
430 void initMpi(int gnx, int gny, int gnz, int nLevels, MPI_Comm comm,
431 const peclet::core::decomp::BlockDecomposer<3>* dec0 = nullptr) {
432 lv_.clear();
433 amg_.reset();
434 distributed_ = true;
435 comm_ = comm;
436 gnxF_ = gnx;
437 gnyF_ = gny;
438 gnzF_ = gnz;
439 int rank = 0, size = 1;
440 MPI_Comm_rank(comm, &rank);
441 MPI_Comm_size(comm, &size);
442 std::array<bool, 3> per{true, true, true};
443 C3 gs{gnx, gny, gnz}, cf{1, 1, 1};
444 auto can = [&](int d) { return (d % 2 == 0) && (d / 2 >= 2); };
445 // Coarse levels are NESTED: level 0 is the shared solver decomposition (dec0), and each coarse
446 // level is the previous level's decomposition coarsened IN PLACE (same tree/leaf order, split
447 // positions halved). This keeps restrict/prolong's coarse-local i <-> fine-local ratio*i mapping
448 // valid on every rank. (An independent ORB per level does NOT nest — coarse blocks can split a
449 // different axis than the fine level, sending restrict/prolong out of bounds.)
450 peclet::core::decomp::BlockDecomposer<3> curDec;
451 if (dec0) {
452 curDec = *dec0; // solver's shared decomposition (built aligned; see flow_ibm initMpi)
453 } else {
454 curDec = decomposition(static_cast<std::size_t>(size), gs.x, gs.y, gs.z);
455 }
456 // Smallest block extent (any rank, any axis) — the same on every rank (the decomposition is
457 // replicated), so the per-level ghost-width decision below is rank-uniform by construction.
458 auto minBlockExtent = [](const peclet::core::decomp::BlockDecomposer<3>& d) {
459 long m = std::numeric_limits<long>::max();
460 for (const auto& s : d.sizes())
461 for (int k = 0; k < 3; ++k)
462 m = std::min(m, (long)s[k]);
463 return m;
464 };
465 for (int L = 0; L < nLevels; ++L) {
466 Level v;
467 v.halo = std::make_shared<GridHaloTopology<3>>();
468 const peclet::core::decomp::BlockDecomposer<3>& dec = curDec;
469 // Communication-avoiding smoothing needs a 2-deep ghost layer; give it to the COARSE levels
470 // (where every halo message is pure latency) whose blocks can carry it (extent >= 4 on every
471 // rank; below that fall back to the per-colour exchange). Level 0 keeps g=1: its exchanges
472 // are already overlapped with the interior sweep and the solver's g=1 bridge (openness/rhs/
473 // phi staging, ghost-projection g=2 staging) assumes it. Only for the periodic/IBM operator
474 // — with domain BCs (setBoundaryConditions BEFORE initMpi) every level keeps the g=1 layout,
475 // so that path is byte-identical to the pre-CA code.
476 v.g = (L > 0 && !hasBC_ && (caSmoothingMode() & kCaMg) && minBlockExtent(dec) >= 4) ? 2 : 1;
477 v.caOk = (v.g == 2);
478 v.halo->buildTopology(dec, rank, v.g, per, comm);
479 v.dev = std::make_shared<GridHalo<double>>();
480 v.dev->init(*v.halo);
481 const auto& idx = v.halo->indexer();
482 const auto eg = idx.sizeInclGhost(), ino = idx.sizeInner(), oig = idx.originInclGhost();
483 v.ext = {(int)eg[0], (int)eg[1], (int)eg[2]};
484 v.inner = {(int)ino[0], (int)ino[1], (int)ino[2]};
485 v.og = {(int)oig[0] + v.g, (int)oig[1] + v.g,
486 (int)oig[2] + v.g}; // inner origin == single-rank og=0 at origin 0
487 v.cfac = cf;
488 v.n = idx.numCellsInclGhost();
489 C3 next = gs, ratio{1, 1, 1};
490 if (L + 1 < nLevels) {
491 // Coarsen an axis only if the GLOBAL dim can (can()) AND every rank's block is even on that
492 // axis, so coarsened() nests exactly. Alignment (coarsenAlignment) makes this hold for the
493 // full natural depth; on an unaligned/awkward decomposition it simply stops coarsening that
494 // axis early (fewer geometric levels, GraphAMG bottom picks up the rest) — never OOB.
495 auto evenBlocks = [&](int ax) {
496 for (std::size_t b = 0; b < curDec.sizes().size(); ++b)
497 if ((curDec.origins()[b][ax] % 2) || (curDec.sizes()[b][ax] % 2))
498 return false;
499 return true;
500 };
501 if (can(gs.x) && evenBlocks(0)) {
502 ratio.x = 2;
503 next.x = gs.x / 2;
504 }
505 if (can(gs.y) && evenBlocks(1)) {
506 ratio.y = 2;
507 next.y = gs.y / 2;
508 }
509 if (can(gs.z) && evenBlocks(2)) {
510 ratio.z = 2;
511 next.z = gs.z / 2;
512 }
513 }
514 v.ratio = ratio;
515 v.x = CCField("mg_x", v.n);
516 v.rhs = CCField("mg_rhs", v.n);
517 v.res = CCField("mg_res", v.n);
518 v.ox = CCField("mg_ox", v.n);
519 v.oy = CCField("mg_oy", v.n);
520 v.oz = CCField("mg_oz", v.n);
521 for (FPV* p : {&v.AC, &v.AW, &v.AE, &v.AS, &v.AN, &v.AB, &v.AT})
522 *p = FPV("mg_A", v.n);
523 lv_.push_back(v);
524 if (next.x == gs.x && next.y == gs.y && next.z == gs.z)
525 break;
526 gs = next;
527 cf = C3{cf.x * ratio.x, cf.y * ratio.y, cf.z * ratio.z};
528 // Next level's decomposition = this level's coarsened in place (nested; preserves rank order).
529 curDec = curDec.coarsened(peclet::core::IVec<3>{ratio.x, ratio.y, ratio.z});
530 }
531 if (mgDebugLevel() && rank == 0) {
532 printf("[mg] initMpi %dx%dx%d np=%d -> %d levels (requested %d)\n", gnx, gny, gnz, size,
533 (int)lv_.size(), nLevels);
534 C3 g{gnx, gny, gnz};
535 for (int L = 0; L < (int)lv_.size(); ++L) {
536 printf("[mg] L%d global %4dx%4dx%4d rank0 block %4dx%4dx%4d ratio(%d,%d,%d)\n", L, g.x,
537 g.y, g.z, lv_[L].inner.x, lv_[L].inner.y, lv_[L].inner.z, lv_[L].ratio.x,
538 lv_[L].ratio.y, lv_[L].ratio.z);
539 g = C3{g.x / lv_[L].ratio.x, g.y / lv_[L].ratio.y, g.z / lv_[L].ratio.z};
540 }
541 fflush(stdout);
542 }
543 }
544#endif
545 int nLevels() const { return (int)lv_.size(); }
546 Level& level(int L) { return lv_[L]; }
547
548 // per-face domain BC types {-x,+x,-y,+y,-z,+z}: 0=periodic, 1/2=Neumann (wall/inflow),
549 // 3=Dirichlet (outflow). Default all-periodic -> applyBoundaryOpenness is a no-op (periodic/IBM
550 // path byte-identical).
551 void setBoundaryConditions(const int bc[6]) {
552 hasBC_ = false;
553 hasOutflow_ = false;
554 for (int i = 0; i < 6; ++i) {
555 bc_[i] = bc[i];
556 if (bc[i])
557 hasBC_ = true;
558 if (bc[i] == 3)
559 hasOutflow_ = true;
560 }
561 removeMean_ =
562 !hasOutflow_; // singular all-Neumann -> remove mean; Dirichlet outflow -> non-singular
563 }
564 // hold the pressure/correction ghost at 0 on outflow faces (open face -> Dirichlet p=0). Call
565 // after every (periodic) fill of a solution / search-direction field, on the level it lives
566 // (g = that level's ghost width).
567 void applyOutflowGhost(C3 ext, CCField x, int g = G) {
568 if (!hasOutflow_)
569 return;
570 B3 e{ext.x, ext.y, ext.z};
571 for (int a = 0; a < 3; ++a)
572 for (int s = 0; s < 2; ++s)
573 if (bc_[2 * a + s] == 3)
574 bcZeroPressureGhost(x, e, g, a, s);
575 }
576 // re-impose the non-periodic boundary openness a periodic fill leaves wrong: Neumann wall/inflow
577 // -> 0 (closed), Dirichlet outflow -> left open. Call after every (periodic) openness fill, per
578 // level.
580 if (!hasBC_)
581 return;
582 B3 e{lv.ext.x, lv.ext.y, lv.ext.z};
583 CCField oa[3] = {lv.ox, lv.oy, lv.oz};
584 for (int a = 0; a < 3; ++a)
585 for (int s = 0; s < 2; ++s) {
586 const int t = bc_[2 * a + s];
587 if (t == 1 || t == 2)
588 bcSetOpenness(oa[a], e, lv.g, a, s, 0.0); // wall/inflow Neumann -> closed
589 else if (t == 3)
590 bcSetOpenness(oa[a], e, lv.g, a, s, 1.0); // outflow -> open (periodic fill wraps wrong)
591 }
592 }
593
594 // rediscretized cut-cell operator on every level from the fine face openness (idx2 = 1/dx^2
595 // fine).
596 void setOpenness(CCConst ox, CCConst oy, CCConst oz, double idx2, double idy2, double idz2) {
597 Level& f = lv_[0];
598 Kokkos::deep_copy(f.ox, ox);
599 Kokkos::deep_copy(f.oy, oy);
600 Kokkos::deep_copy(f.oz, oz);
602 f); // periodic fine-level openness ghosts (the operator reads the + neighbour face);
603 // idempotent when the caller already filled them, required when it passed inner-only.
605 f); // re-impose non-periodic wall/inflow faces the periodic fill clobbered
606 buildCutcellOp(f.AC, f.AW, f.AE, f.AS, f.AN, f.AB, f.AT, CCConst(f.ox), CCConst(f.oy),
607 CCConst(f.oz), f.ext, G, idx2, idy2, idz2);
608 for (int L = 1; L < (int)lv_.size(); ++L) {
609 Level& c = lv_[L];
610 Level& fin = lv_[L - 1];
611 coarsenOpenAvg(c.ox, c.oy, c.oz, CCConst(fin.ox), CCConst(fin.oy), CCConst(fin.oz), c.ext,
612 fin.ext, c.g, fin.g, c.inner, fin.ratio);
613 fillOpenness(c); // periodic ghost openness (operator build reads the + neighbour face)
614 applyBoundaryOpenness(c); // re-impose non-periodic boundary faces per coarse level
615 const double sx = 1.0 / (double)(c.cfac.x * c.cfac.x),
616 sy = 1.0 / (double)(c.cfac.y * c.cfac.y),
617 sz = 1.0 / (double)(c.cfac.z * c.cfac.z);
618 // Width-2 (CA-eligible) levels also assemble the 1-deep ghost RING of the operator (build
619 // box widened by 1): the ring rows are a deterministic function of the EXCHANGED openness,
620 // so they come out bit-identical to the owning rank's inner rows — the redundant ring
621 // re-smoothing of the CA sweep reads them. Inner rows are computed from the same operands
622 // as the g-box build (identical). g=1 levels keep the inner-only build.
623 buildCutcellOp(c.AC, c.AW, c.AE, c.AS, c.AN, c.AB, c.AT, CCConst(c.ox), CCConst(c.oy),
624 CCConst(c.oz), c.ext, c.g == 2 ? c.g - 1 : c.g, idx2 * sx, idy2 * sy,
625 idz2 * sz);
626 }
627 // The operator (all levels, including the bottom) just changed: invalidate the agglomerated
628 // GraphAMG bottom solve so the next solve rebuilds it from the CURRENT coefficients. The porous
629 // and variable-rho paths rebuild the coefficients EVERY STEP — with a stale AMG bottom (frozen
630 // at the first step's operator) the bottom "solve" answers a different matrix, the V-cycle
631 // preconditioner drifts inconsistent/indefinite, and the outer PCG eventually breaks down and
632 // NaNs the projection (observed as a sporadic, data-dependent blow-up in porous CFD-DEM). The
633 // bottom level is tiny, so the per-step rebuild is negligible next to the V-cycles.
634 amg_.reset();
635 amgGlobalN_ = 0;
636 }
637
638 // CG preconditioned by one symmetric V-cycle (solve_pcg port). rhs on level 0; solution left in
639 // level-0 x. Returns the iteration count. Scratch supplied by the caller (level-0-sized fields).
640 // Optional star overlay (mode-B fluid-only constraint): the SPD Kron-elimination couplings are
641 // added to the fine-level matvec only; the hierarchy/preconditioner sees the filtered 7-point
642 // surrogate it was built from (the symmetric sibling of solveBiCGStab's gp overlay pattern).
643 // Single-rank v1: the star kernels wrap periodically over the inner grid, so no halo work.
645 double rtol, int pre, int post, int bottom, const StarOverlay* star = nullptr,
646 int nStar = 0, C3 nnStar = C3{0, 0, 0}) {
647 pre_ = pre;
648 post_ = post;
649 bottom_ = bottom;
650 Level& l0 = lv_[0];
651 Kokkos::deep_copy(l0.x, x);
652 auto matvec = [&](CCField y, CCField v) {
653 matvecOverlap(l0, y, v);
654 if (star)
655 starApplyDelta(y, CCConst(v), *star, nStar, nnStar, l0.ext, G, l0.ext, G);
656 };
657 auto precond = [&](CCField zz, CCField rr) {
658 Kokkos::deep_copy(l0.rhs, rr);
659 Kokkos::deep_copy(l0.x, 0.0);
660 vcycle(0, /*sym=*/true);
661 Kokkos::deep_copy(zz, l0.x);
662 };
663 matvec(Ap, x); // r = b - A x
664 Kokkos::deep_copy(r, b);
665 axpy(r, -1.0, Ap);
666 removeMean(l0, r); // compatibility: project rhs/residual onto the range
667 const double r0 = maxabs(l0, r);
668 int it = 0;
669 // Env-gated convergence trace (PECLET_FLOW_MG_DEBUG>=2): |b|inf, r0 and the per-iteration
670 // residual, so a decomposition-dependent iteration count can be read as a rate (preconditioner
671 // quality) or a floor (round-off) instead of guessed at.
672 int dbgRank = 0;
673#ifdef PECLET_FLOW_MPI
674 if (distributed_)
676#endif
677 const bool trace = mgDebugLevel() >= 2 && dbgSolve_ < mgDebugSolves() && dbgRank == 0;
678 if (trace)
679 printf("[mg] solve %d: r0=%.6e rtol=%.1e (pre=%d post=%d bottom=%d)\n", dbgSolve_, r0, rtol,
680 pre, post, bottom);
681 ++dbgSolve_;
682 // Breakdown guards: a non-finite recurrence scalar means the preconditioner or operator
683 // produced NaN/Inf (should not happen — the guards fail safe rather than poisoning x with a
684 // NaN alpha/beta and letting the projection silently corrupt every field downstream).
685 if (r0 > 0.0 && std::isfinite(r0)) {
686 precond(z, r);
687 Kokkos::deep_copy(p, z);
688 double rz = dot(l0, r, z);
689 if (!std::isfinite(rz)) {
690 printf("peclet::flow CutcellMG::solvePCG: preconditioner produced non-finite z; "
691 "returning zero correction\n");
692 Kokkos::deep_copy(x, 0.0);
693 Kokkos::deep_copy(l0.x, x);
694 return 0;
695 }
696 for (; it < maxit; ++it) {
697 matvec(Ap, p);
698 if (meanRemovalAll_)
699 removeMean(l0, Ap); // A preserves mean-freeness; "fine" scope trusts that
700 const double pAp = dot(l0, p, Ap);
701 if (!std::isfinite(pAp) || pAp <= 1e-300)
702 break; // breakdown/converged direction: keep the last finite iterate
703 const double alpha = rz / pAp;
704 axpy(x, alpha, p);
705 axpy(r, -alpha, Ap);
706 removeMean(l0, r);
707 const double rn = maxabs(l0, r);
708 if (trace)
709 printf("[mg] it %3d |r|inf=%.6e r/r0=%.4e\n", it + 1, rn, rn / r0);
710 if (rn < rtol * r0) {
711 ++it;
712 break;
713 }
714 precond(z, r);
715 const double rznew = dot(l0, r, z), beta = rznew / rz;
716 if (!std::isfinite(rznew))
717 break; // preconditioner breakdown: keep the last finite iterate
718 aypx(p, beta, z);
719 rz = rznew;
720 }
721 }
722 Kokkos::deep_copy(l0.x, x);
723 removeMean(l0, l0.x);
724 Kokkos::deep_copy(x, l0.x);
725 return it;
726 }
727
728 // BiCGStab preconditioned by one symmetric V-cycle, for the NONSYMMETRIC ghost-projection
729 // operator A = (binary-openness 7-point op) + (per-row overlay delta, gpApplyDelta). The MG
730 // hierarchy/preconditioner only ever sees the symmetric binary surrogate its levels were built
731 // from (setOpenness); the overlay enters the fine-level matvec only. Same breakdown guards +
732 // constant-mode (mean) removal as solvePCG, plus a stagnation guard: the nonsymmetric system's
733 // left null vector is NOT exactly the constants, so the attainable residual has a small
734 // compatibility floor — stop when no progress instead of burning maxit. Scratch: 7 level-0
735 // fields from the caller. Returns the iteration count.
736 // Distributed: the reductions/mean removal already Allreduce and the V-cycle is MPI-folded; the
737 // fine-level matvec is the one gp-specific piece. The overlay couplings reach +/-2 but the MG
738 // block only has a g=1 halo, so the caller passes a g=2 staging field (xg2, on its ext2 block)
739 // + that block's halo: stage q's inner cells there, exchange the 2-deep halo once, read the g=1
740 // halo back from the staged copy (one exchange serves both the 7-point op and the overlay), and
741 // apply the overlay in ghost mode. Single-rank (h2 == nullptr) is byte-identical to before.
743 CCField z, CCField z2, int maxit, double rtol, int pre, int post, int bottom,
744 const GpOverlay& ov, int nOv, C3 nn
746 ,
747 CCField xg2 = CCField(), GridHalo<double>* h2 = nullptr, C3 ext2 = C3{0, 0, 0}
748#endif
749 ) {
750 pre_ = pre;
751 post_ = post;
752 bottom_ = bottom;
753 Level& l0 = lv_[0];
754 auto matvec = [&](CCField y, CCField q) {
755#ifdef PECLET_FLOW_MPI
756 if (distributed_ && h2) {
757 stageG2(l0, q, xg2, ext2); // inner cells g=1 block -> g=2 block
758 h2->exchange(xg2); // 2-deep halo (cross-rank + periodic)
759 unstageG2(l0, q, xg2); // whole l0 block back (fills q's g=1 halo — no 2nd exchange)
760 applyOutflowGhost(l0.ext, q);
761 applyCutcellOp(y, CCConst(q), FPC(l0.AC), FPC(l0.AW), FPC(l0.AE), FPC(l0.AS), FPC(l0.AN),
762 FPC(l0.AB), FPC(l0.AT), l0.ext, G);
763 gpApplyDelta(y, CCConst(xg2), ov, nOv, nn, l0.ext, G, ext2, 2, /*useGhost=*/true);
764 return;
765 }
766#endif
767 fill(l0, q);
768 applyOutflowGhost(l0.ext, q);
769 applyCutcellOp(y, CCConst(q), FPC(l0.AC), FPC(l0.AW), FPC(l0.AE), FPC(l0.AS), FPC(l0.AN),
770 FPC(l0.AB), FPC(l0.AT), l0.ext, G);
771 gpApplyDelta(y, CCConst(q), ov, nOv, nn, l0.ext, G, l0.ext, G);
772 };
773 auto precond = [&](CCField zz, CCField rr) {
774 Kokkos::deep_copy(l0.rhs, rr);
775 Kokkos::deep_copy(l0.x, 0.0);
776 vcycle(0, /*sym=*/true);
777 Kokkos::deep_copy(zz, l0.x);
778 };
779 matvec(t, x); // r = b - A x (t as scratch)
780 Kokkos::deep_copy(r, b);
781 axpy(r, -1.0, t);
782 removeMean(l0, r);
783 Kokkos::deep_copy(rh, r); // shadow residual r^ = r_0
784 const double r0n = maxabs(l0, r);
785 int it = 0;
786 if (r0n > 0.0 && std::isfinite(r0n)) {
787 double rho = 1.0, alpha = 1.0, omega = 1.0;
788 double best = r0n;
789 int lastImprove = 0;
790 Kokkos::deep_copy(p, 0.0);
791 Kokkos::deep_copy(v, 0.0);
792 for (; it < maxit; ++it) {
793 const double rhoNew = dot(l0, rh, r);
794 if (!std::isfinite(rhoNew) || std::fabs(rhoNew) < 1e-300)
795 break; // (rh, r) breakdown: keep the last finite iterate
796 const double beta = (rhoNew / rho) * (alpha / omega);
797 rho = rhoNew;
798 axpy(p, -omega, v); // p = r + beta (p - omega v)
799 aypx(p, beta, r);
800 precond(z, p);
801 matvec(v, z);
802 removeMean(l0, v);
803 const double rhv = dot(l0, rh, v);
804 if (!std::isfinite(rhv) || std::fabs(rhv) < 1e-300)
805 break;
806 alpha = rho / rhv;
807 axpy(r, -alpha, v); // r <- s = r - alpha v
808 removeMean(l0, r);
809 double rn = maxabs(l0, r);
810 if (!std::isfinite(rn))
811 break;
812 if (rn < rtol * r0n) {
813 axpy(x, alpha, z);
814 ++it;
815 break;
816 }
817 precond(z2, r);
818 matvec(t, z2);
819 removeMean(l0, t);
820 const double tt = dot(l0, t, t);
821 if (!std::isfinite(tt) || tt < 1e-300) {
822 axpy(x, alpha, z); // omega breakdown: take the alpha half-step and stop
823 break;
824 }
825 omega = dot(l0, t, r) / tt;
826 if (!std::isfinite(omega) || std::fabs(omega) < 1e-300) {
827 axpy(x, alpha, z);
828 break;
829 }
830 axpy(x, alpha, z);
831 axpy(x, omega, z2);
832 axpy(r, -omega, t);
833 removeMean(l0, r);
834 rn = maxabs(l0, r);
835 if (!std::isfinite(rn))
836 break;
837 if (rn < rtol * r0n) {
838 ++it;
839 break;
840 }
841 if (rn < 0.999 * best) {
842 best = rn;
843 lastImprove = it;
844 } else if (it - lastImprove > 30) {
845 ++it; // compatibility-floor stagnation: accept the best-so-far level
846 break;
847 }
848 }
849 }
850 Kokkos::deep_copy(l0.x, x);
851 removeMean(l0, l0.x);
852 Kokkos::deep_copy(x, l0.x);
853 return it;
854 }
855
856 public: // (public for nvcc extended-lambda rule)
857 // Per-level V-cycle wall time (PECLET_FLOW_MG_DEBUG>=3; HOST backends only — no device fence, so
858 // on CUDA the numbers are launch times, not kernel times). Answers "how much of the solve is
859 // spent on the small coarse levels", i.e. whether coarse-level launch overhead is worth chasing.
860 void vcycle(int L, bool sym) {
861 if (mgDebugLevel() >= 3) {
862 const auto t0 = std::chrono::steady_clock::now();
863 vcycleImpl(L, sym);
864 lvTime_.resize(lv_.size(), 0.0);
865 lvTime_[L] += std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count();
866 if (L == 0 && ++lvCycles_ % 50 == 0) {
867 double tot = 0;
868 for (std::size_t i = 0; i < lvTime_.size(); ++i)
869 tot += (i + 1 < lvTime_.size() ? lvTime_[i] - lvTime_[i + 1] : lvTime_[i]);
870 printf("[mg] level times over %d V-cycles (total %.3f s):\n", lvCycles_, tot);
871 for (std::size_t i = 0; i < lvTime_.size(); ++i) {
872 const double self = (i + 1 < lvTime_.size() ? lvTime_[i] - lvTime_[i + 1] : lvTime_[i]);
873 printf("[mg] L%zu %5dx%5dx%5d self %7.3f s (%5.1f%%)\n", i, lv_[i].inner.x,
874 lv_[i].inner.y, lv_[i].inner.z, self, 100.0 * self / (tot + 1e-30));
875 }
876 fflush(stdout);
877 }
878 return;
879 }
880 vcycleImpl(L, sym);
881 }
882 void vcycleImpl(int L, bool sym) {
883 Level& lv = lv_[L];
884 if (L + 1 == (int)lv_.size()) {
885 if (agglomerateBottom())
887 lv); // agglomerated mesh-agnostic coarse solve (decomposition-agnostic)
888 else
889 smooth(lv, bottom_, false);
890 if (meanRemovalAll_)
891 removeMean(lv, lv.x);
892 return;
893 }
894 smooth(lv, pre_, false);
895 // Refresh the halo before the residual. The smoother exchanges BEFORE each color sweep, so on
896 // return the ghosts are one color-update stale: the residual — and hence the restricted coarse
897 // rhs — is wrong on the block-boundary shell. That perturbation is proportional to the block
898 // SURFACE, so it made the V-cycle's convergence rate decomposition-dependent: fat blocks (few
899 // ranks) needed measurably more Krylov iterations than thin ones on the SAME grid (768x640x384
900 // genoa: 12 iters at 12-24 ranks vs 8.1 at 96; 256^3 workstation: 8/6.5/9 at np=1/8/24 -> a
901 // flat 4.0 with the refresh). Distributed: overlap the exchange with the interior residual
902 // (same interior/shell split as the smoother); single-rank: the periodic wrap copy.
903 auto fullResidual = [&] {
904 residualCutcell(lv.res, CCConst(lv.x), CCConst(lv.rhs), FPC(lv.AC), FPC(lv.AW), FPC(lv.AE),
905 FPC(lv.AS), FPC(lv.AN), FPC(lv.AB), FPC(lv.AT), lv.ext, lv.g);
906 };
907 if (!resFill_) { // PECLET_FLOW_MG_RESFILL=0: the legacy stale-ghost residual (ablation only)
908 fullResidual();
909 }
910#ifdef PECLET_FLOW_MPI
911 else if (distributed_) {
912 const int g = lv.g;
913 const C3 lo{g + 1, g + 1, g + 1};
914 const C3 hi{lv.ext.x - g - 1, lv.ext.y - g - 1, lv.ext.z - g - 1};
915 lv.dev->exchangeBegin(lv.x);
916 residualCutcellBox(lv.res, CCConst(lv.x), CCConst(lv.rhs), FPC(lv.AC), FPC(lv.AW),
917 FPC(lv.AE), FPC(lv.AS), FPC(lv.AN), FPC(lv.AB), FPC(lv.AT), lv.ext, lo, hi,
918 C3{0, 0, 0}, C3{0, 0, 0});
919 lv.dev->exchangeEnd(lv.x);
920 applyOutflowGhost(lv.ext, lv.x, g);
921 residualCutcellBox(lv.res, CCConst(lv.x), CCConst(lv.rhs), FPC(lv.AC), FPC(lv.AW),
922 FPC(lv.AE), FPC(lv.AS), FPC(lv.AN), FPC(lv.AB), FPC(lv.AT), lv.ext,
923 C3{g, g, g}, C3{lv.ext.x - g, lv.ext.y - g, lv.ext.z - g}, lo, hi);
924 } else
925#endif
926 {
927 fill(lv, lv.x); // single-rank: the periodic wrap copy
928 applyOutflowGhost(lv.ext, lv.x, lv.g);
929 fullResidual();
930 }
931 Level& cs = lv_[L + 1];
932 restrictAvg(cs.rhs, CCConst(lv.res), cs.ext, lv.ext, cs.g, lv.g, cs.inner, lv.ratio);
933 Kokkos::deep_copy(cs.x, 0.0);
934 vcycle(L + 1, sym);
935 fill(cs, cs.x);
936 applyOutflowGhost(cs.ext, cs.x, cs.g);
937 prolongAdd(lv.x, CCConst(cs.x), lv.ext, cs.ext, lv.g, cs.g, lv.inner, lv.ratio);
938 smooth(lv, post_, /*reverse=*/sym);
939 if (meanRemovalAll_ || L == 0)
940 removeMean(lv, lv.x);
941 }
942 // Communication-avoiding smoothing on this level? Needs the width-2 topology (caOk), and the
943 // periodic/IBM operator — with domain BCs the ring rows would need post-BC ghost openness the
944 // exchange does not deliver, so those keep the per-colour exchange.
945 bool caSmooth(const Level& lv) const { return distributed_ && lv.caOk && !hasBC_; }
946 void smooth(Level& lv, int sweeps, bool reverse) {
947 const C3 og = parityOg(lv); // red-black parity origin ({0,0,0} single-rank)
948#ifdef PECLET_FLOW_MPI
949 if (caSmooth(lv)) {
950 // Communication-avoiding pair: ONE 2-deep exchange per red-black pair instead of a 1-deep
951 // exchange per colour. The first colour overlaps its exchange with the interior sweep, then
952 // sweeps the boundary shell PLUS the 1-deep ghost ring — redundantly recomputing the
953 // neighbour's boundary cells from the same operands the neighbour uses (2-deep x ghosts,
954 // ring rows of the operator and rhs are exchanged/assembled bit-identical), so the ring
955 // values come out equal to what a fresh exchange would deliver. The second colour then
956 // sweeps with NO exchange: its boundary cells read only first-colour ring cells (a colour
957 // never reads its own colour). Bit-identical to the per-colour exchange at half the events.
958 const int g = lv.g;
959 const C3 lo{g + 1, g + 1, g + 1};
960 const C3 hi{lv.ext.x - g - 1, lv.ext.y - g - 1, lv.ext.z - g - 1};
961 const C3 rlo{g - 1, g - 1, g - 1};
962 const C3 rhi{lv.ext.x - g + 1, lv.ext.y - g + 1, lv.ext.z - g + 1};
963 lv.dev->exchange(lv.rhs); // ring rhs (owner's inner values); rhs is fixed over the sweeps
964 for (int k = 0; k < sweeps; ++k) {
965 const int c0 = reverse ? 1 : 0, c1 = 1 - c0;
966 lv.dev->exchangeBegin(lv.x);
967 cutcellSmoothColorBox(lv.x, CCConst(lv.rhs), FPC(lv.AC), FPC(lv.AW), FPC(lv.AE),
968 FPC(lv.AS), FPC(lv.AN), FPC(lv.AB), FPC(lv.AT), lv.ext, og, c0, lo,
969 hi, C3{0, 0, 0}, C3{0, 0, 0});
970 lv.dev->exchangeEnd(lv.x);
971 cutcellSmoothColorBox(lv.x, CCConst(lv.rhs), FPC(lv.AC), FPC(lv.AW), FPC(lv.AE),
972 FPC(lv.AS), FPC(lv.AN), FPC(lv.AB), FPC(lv.AT), lv.ext, og, c0, rlo,
973 rhi, lo, hi);
974 cutcellSmoothColor(lv.x, CCConst(lv.rhs), FPC(lv.AC), FPC(lv.AW), FPC(lv.AE), FPC(lv.AS),
975 FPC(lv.AN), FPC(lv.AB), FPC(lv.AT), lv.ext, og, g, c1);
976 }
977 return;
978 }
979#endif
980 for (int k = 0; k < sweeps; ++k)
981 for (int s = 0; s < 2; ++s) {
982 const int color = reverse ? (1 - s) : s;
983#ifdef PECLET_FLOW_MPI
984 if (distributed_) {
985 // Overlap the per-color halo with the interior sweep: post the exchange, smooth the
986 // cells whose 7-point stencil reads no ghost (they depend on neither the incoming halo
987 // nor the outflow ghost), complete the exchange, then sweep the boundary shell. A
988 // color's cells never read same-color cells, so this ordering is bit-identical to the
989 // blocking fill-then-full-sweep (validated by the np>1 bit-exact MG tests).
990 const int g = lv.g;
991 const C3 lo{g + 1, g + 1, g + 1};
992 const C3 hi{lv.ext.x - g - 1, lv.ext.y - g - 1, lv.ext.z - g - 1};
993 lv.dev->exchangeBegin(lv.x);
994 cutcellSmoothColorBox(lv.x, CCConst(lv.rhs), FPC(lv.AC), FPC(lv.AW), FPC(lv.AE),
995 FPC(lv.AS), FPC(lv.AN), FPC(lv.AB), FPC(lv.AT), lv.ext, og, color,
996 lo, hi, C3{0, 0, 0}, C3{0, 0, 0});
997 lv.dev->exchangeEnd(lv.x);
998 applyOutflowGhost(lv.ext, lv.x, g);
999 cutcellSmoothColorBox(lv.x, CCConst(lv.rhs), FPC(lv.AC), FPC(lv.AW), FPC(lv.AE),
1000 FPC(lv.AS), FPC(lv.AN), FPC(lv.AB), FPC(lv.AT), lv.ext, og, color,
1001 C3{g, g, g}, C3{lv.ext.x - g, lv.ext.y - g, lv.ext.z - g}, lo, hi);
1002 continue;
1003 }
1004#endif
1005 fill(lv, lv.x);
1006 applyOutflowGhost(lv.ext, lv.x, lv.g);
1007 cutcellSmoothColor(lv.x, CCConst(lv.rhs), FPC(lv.AC), FPC(lv.AW), FPC(lv.AE), FPC(lv.AS),
1008 FPC(lv.AN), FPC(lv.AB), FPC(lv.AT), lv.ext, og, lv.g, color);
1009 }
1010 }
1011
1012 // --- when to agglomerate ------------------------------------------------------------------------
1013 // A V-cycle only converges at a rate independent of the domain if its COARSEST level is small
1014 // enough to be solved (essentially) exactly by the few smoother sweeps applied there. A geometric
1015 // hierarchy cannot always get there: an axis stops coarsening once it turns odd, and under MPI it
1016 // stops once any rank's block turns odd — so on a fixed per-rank block the coarsest GLOBAL grid
1017 // grows with the rank count and the bottom is progressively under-solved. That is the mechanism
1018 // behind weak-scaling curves that decay while communication stays negligible.
1019 //
1020 // Measured (single GPU, channel, Lx = 2048 x 64 x 64, everything else held): a smoothed bottom
1021 // needs 13.5 pressure iterations/step at 4 levels and 6.0 at 6 levels, against 4.4 at full
1022 // geometric depth. Agglomerating and solving that same bottom exactly gives 4.0 at BOTH 4 and 6
1023 // levels — depth-independent, and faster in wall-clock than the full-depth hierarchy (69.5 vs
1024 // 77.1 ms/step) because the extra levels cost more than the coarse solve they replace.
1025 //
1026 // NOT the default yet, and the reason is measured: on the cut-cell sphere-packing regression
1027 // (random_spheres, N=48) switching the bottom to the agglomerated solve makes the OUTER iteration
1028 // count WORSE (442 -> 622 total, +41 %) at unchanged accuracy, so the assembled coarse operator is
1029 // evidently not consistent with the V-cycle's on that IBM path. Until that is understood, `auto`
1030 // is opt-in and the legacy smoothed bottom stays the default.
1031 // `mode`: 0 = never / plain smoothed bottom (DEFAULT), -1 = auto, 1 = always.
1032 // PECLET_FLOW_AGGLOM_CELLS overrides the threshold; the ideal bottom is a handful of cells per
1033 // axis, and 512 is a generous cut that leaves genuinely small bottoms on the cheap path.
1034 bool agglomerateBottom() const {
1035 if (agglomMode_ == 0)
1036 return false;
1037 if (agglomMode_ == 1)
1038 return true;
1039 if (lv_.empty())
1040 return false;
1041 // Auto engages only for the SINGULAR (periodic / all-Neumann / IBM) operator. On the
1042 // Dirichlet-anchored (outflow) path the exact bottom measurably LOWERS the outer solve's
1043 // attainable floor (128x32x32 inflow/outflow channel: flux divergence floor 8e-8 smoothed vs
1044 // 2e-5 agglomerated at identical budgets; the CSR solution satisfies the V-cycle's own bottom
1045 // operator to 1e-9, so this is not operator mismatch — the anchored operator's near-null mode
1046 // makes the exact bottom return O(1e3 |b|) corrections whose float-hierarchy round-off the
1047 // smoothed bottom never generates). Until that is understood, anchored operators keep the
1048 // smoothed bottom; set_pressure_bottom("agglomerated") still forces it anywhere.
1049 if (!removeMean_)
1050 return false;
1051 // The criterion is the coarsest grid's largest EXTENT, not its cell count: what a few smoother
1052 // sweeps cannot fix is a mode spanning many cells along an axis, and Gauss-Seidel needs O(L^2)
1053 // sweeps to damp a wavelength of L cells. A 64x2x2 bottom is only 256 cells yet still 64 across
1054 // -- measured, that costs 6.0 pressure iterations/step against 4.0 for an exact solve.
1055 static const int thresh = [] {
1056 const char* e = std::getenv("PECLET_FLOW_AGGLOM_EXTENT");
1057 const int v = e ? std::atoi(e) : 4;
1058 return v > 0 ? v : 4;
1059 }();
1060 // coarsest GLOBAL cell count (the local block does not decide how hard the coarse solve is)
1061 long gx = gnxF_, gy = gnyF_, gz = gnzF_;
1062 for (int L = 0; L + 1 < (int)lv_.size(); ++L) {
1063 gx /= lv_[L].ratio.x;
1064 gy /= lv_[L].ratio.y;
1065 gz /= lv_[L].ratio.z;
1066 }
1067 return gx > thresh || gy > thresh || gz > thresh;
1068 }
1069
1070 // --- Agglomerated GraphAMG bottom solve --------------------------------------------------------
1071 // Assemble the coarsest level's cut-cell operator as a GLOBAL CSR (gathered to rank 0) and build
1072 // a mesh-agnostic smoothed-aggregation AMG on it. Decomposition-agnostic: the CSR is keyed by
1073 // GLOBAL cell id (periodic-wrapped neighbours), so any (weighted) ORB gives the SAME operator.
1074 void buildAmg(Level& lv) {
1075 int gbx = gnxF_, gby = gnyF_,
1076 gbz = gnzF_; // bottom global dims (coarsen by the ratios above it)
1077 for (int L = 0; L + 1 < (int)lv_.size(); ++L) {
1078 gbx /= lv_[L].ratio.x;
1079 gby /= lv_[L].ratio.y;
1080 gbz /= lv_[L].ratio.z;
1081 }
1082 amgGlobalN_ = gbx * gby * gbz;
1083 const int nx = lv.inner.x, ny = lv.inner.y, nz = lv.inner.z, ex = lv.ext.x, ey = lv.ext.y;
1084 auto host = [](FPV v) {
1085 auto h = Kokkos::create_mirror_view(v);
1086 Kokkos::deep_copy(h, v);
1087 return h;
1088 };
1089 auto hC = host(lv.AC), hW = host(lv.AW), hE = host(lv.AE), hS = host(lv.AS), hN = host(lv.AN),
1090 hB = host(lv.AB), hT = host(lv.AT);
1091 // this rank's rows: (gid, diag) and off-diagonals (gid -> ngid, coef), periodic-wrapped.
1092 std::vector<int> lgid, lrow, lcol;
1093 std::vector<double> ldiag, lval;
1094 std::vector<std::uint8_t> lsolid; // AGMG_DEBUG: identity-row marker, per local row
1095 amgGlobalOfLocal_.clear();
1096 const int band[6][3] = {{-1, 0, 0}, {1, 0, 0}, {0, -1, 0}, {0, 1, 0}, {0, 0, -1}, {0, 0, 1}};
1097 const int g = lv.g;
1098 for (int k = 0; k < nz; ++k)
1099 for (int j = 0; j < ny; ++j)
1100 for (int i = 0; i < nx; ++i) {
1101 const long p = (long)(i + g) + (long)(j + g) * ex + (long)(k + g) * ex * ey;
1102 const int gx = lv.og.x + i, gy = lv.og.y + j, gz = lv.og.z + k;
1103 const int gid = gx + gy * gbx + gz * gbx * gby;
1104 amgGlobalOfLocal_.push_back(gid);
1105 lgid.push_back(gid);
1106 // solid cells (all faces closed => diag 0, no coupling) get an identity row so D^-1 is
1107 // finite; their rhs is 0, so x stays 0 (correct — no flow inside the solid).
1108 const double dc = (double)hC(p);
1109 ldiag.push_back(dc != 0.0 ? dc : 1.0);
1110 lsolid.push_back(dc == 0.0 ? 1 : 0);
1111 const double bc[6] = {(double)hW(p), (double)hE(p), (double)hS(p),
1112 (double)hN(p), (double)hB(p), (double)hT(p)};
1113 for (int d = 0; d < 6; ++d) {
1114 if (bc[d] == 0.0)
1115 continue; // closed face (wall) -> no coupling
1116 // A face crossing the domain boundary couples to the wrapped cell ONLY on a periodic
1117 // axis (bc_ type 0). On a non-periodic axis an OPEN boundary face is the Dirichlet
1118 // outflow anchor: its coefficient lives in the diagonal (already in AC) with NO
1119 // off-diagonal partner — wrapping it would add a spurious top<->bottom coupling and
1120 // (with the mean projection skipped) a wrong, possibly indefinite bottom matrix.
1121 const int rx = gx + band[d][0], ry = gy + band[d][1], rz = gz + band[d][2];
1122 const int axis = d / 2;
1123 const bool crosses = (axis == 0 && (rx < 0 || rx >= gbx)) ||
1124 (axis == 1 && (ry < 0 || ry >= gby)) ||
1125 (axis == 2 && (rz < 0 || rz >= gbz));
1126 if (crosses && bc_[d] != 0)
1127 continue; // non-periodic boundary face: Dirichlet anchor stays diagonal-only
1128 const int ngx = (rx % gbx + gbx) % gbx;
1129 const int ngy = (ry % gby + gby) % gby;
1130 const int ngz = (rz % gbz + gbz) % gbz;
1131 lrow.push_back(gid);
1132 lcol.push_back(ngx + ngy * gbx + ngz * gbx * gby);
1133 lval.push_back(bc[d]);
1134 }
1135 }
1136 // all-gather every rank's rows (no-op / identity single-rank): EVERY rank assembles the same
1137 // global CSR and builds the same AMG (redundant coarse solve).
1138 std::vector<int> ggid = lgid, grow = lrow, gcol = lcol;
1139 std::vector<double> gdiag = ldiag, gval = lval;
1140 std::vector<std::uint8_t> gsolid = lsolid;
1141#ifdef PECLET_FLOW_MPI
1142 if (distributed_) {
1143 gatherv(lgid, ggid);
1145 gatherv(lrow, grow);
1146 gatherv(lcol, gcol);
1147 gatherv(lval, gval);
1149 }
1150#endif
1151 amgSolid_.assign((std::size_t)amgGlobalN_, 0);
1152 for (std::size_t r = 0; r < ggid.size(); ++r)
1153 amgSolid_[(std::size_t)ggid[r]] = gsolid[r];
1154 { // connected components of the operator graph (union-find over the off-diagonal edges):
1155 // each FLUID component carries its own constant null vector, so the null-space projection
1156 // must be per-component. Solid identity rows are singletons and take no projection.
1157 std::vector<int> parent((std::size_t)amgGlobalN_);
1158 for (int i = 0; i < amgGlobalN_; ++i)
1159 parent[(std::size_t)i] = i;
1160 auto find = [&](int a) {
1161 while (parent[(std::size_t)a] != a)
1162 a = parent[(std::size_t)a] = parent[(std::size_t)parent[(std::size_t)a]];
1163 return a;
1164 };
1165 for (std::size_t e = 0; e < grow.size(); ++e) {
1166 const int ra = find(grow[e]), rb = find(gcol[e]);
1167 if (ra != rb)
1168 parent[(std::size_t)ra] = rb;
1169 }
1170 amgComp_.assign((std::size_t)amgGlobalN_, -1);
1171 std::vector<int> remap((std::size_t)amgGlobalN_, -1);
1172 amgNComp_ = 0;
1173 for (int i = 0; i < amgGlobalN_; ++i) {
1174 if (amgSolid_[(std::size_t)i])
1175 continue; // identity row: no null space, excluded from projection
1176 const int r = find(i);
1177 if (remap[(std::size_t)r] < 0)
1178 remap[(std::size_t)r] = amgNComp_++;
1179 amgComp_[(std::size_t)i] = remap[(std::size_t)r];
1180 }
1181 }
1182 if (agmgDebug()) {
1183 std::vector<long> csize((std::size_t)amgNComp_, 0);
1184 for (int i = 0; i < amgGlobalN_; ++i)
1185 if (amgComp_[(std::size_t)i] >= 0)
1186 ++csize[(std::size_t)amgComp_[(std::size_t)i]];
1187 printf("[agmg-build] fluid components=%d sizes:", amgNComp_);
1188 for (int c = 0; c < std::min(amgNComp_, 12); ++c)
1189 printf(" %ld", csize[(std::size_t)c]);
1190 printf(amgNComp_ > 12 ? " ...\n" : "\n");
1191 }
1192 if (agmgDebug()) {
1193 long nSolid = 0, nTiny30 = 0, nTiny12 = 0;
1194 double minFluidDiag = 1e300, maxFluidDiag = 0;
1195 for (std::size_t r = 0; r < ggid.size(); ++r) {
1196 if (gsolid[r]) {
1197 ++nSolid;
1198 continue;
1199 }
1200 const double ad = std::fabs(gdiag[r]);
1201 minFluidDiag = std::min(minFluidDiag, ad);
1202 maxFluidDiag = std::max(maxFluidDiag, ad);
1203 nTiny30 += ad < 1e-30;
1204 nTiny12 += ad < 1e-12;
1205 }
1206 printf("[agmg-build] n=%d solid=%ld fluid=%ld fluid|diag| min=%.3e max=%.3e "
1207 "tiny<1e-30=%ld <1e-12=%ld\n",
1208 amgGlobalN_, nSolid, (long)ggid.size() - nSolid, minFluidDiag, maxFluidDiag, nTiny30,
1209 nTiny12);
1210 // Row-sum defect: the operator's null vector is the constant ONLY if every fluid row sums
1211 // to zero. The level coefficients are stored in float (MReal), so the diagonal is a
1212 // float-rounded sum of the face coefficients — a nonzero defect here bounds how far a
1213 // singular-consistent solve can converge.
1214 std::vector<double> rowsum((std::size_t)amgGlobalN_, 0.0);
1215 for (std::size_t r = 0; r < ggid.size(); ++r)
1216 rowsum[(std::size_t)ggid[r]] = gsolid[r] ? 0.0 : gdiag[r];
1217 for (std::size_t e = 0; e < grow.size(); ++e)
1218 if (!amgSolid_[(std::size_t)grow[e]])
1219 rowsum[(std::size_t)grow[e]] += gval[e];
1220 double defMax = 0, defRelMax = 0;
1221 for (std::size_t r = 0; r < ggid.size(); ++r)
1222 if (!gsolid[r]) {
1223 const double d = std::fabs(rowsum[(std::size_t)ggid[r]]);
1224 defMax = std::max(defMax, d);
1225 defRelMax = std::max(defRelMax, d / std::fabs(gdiag[r]));
1226 }
1227 printf("[agmg-build] fluid row-sum defect max=%.3e rel=%.3e\n", defMax, defRelMax);
1228 fflush(stdout);
1229 }
1230 { // assemble the global CSR keyed by gid
1231 peclet::core::solver::HostCsrOp A;
1232 A.n = amgGlobalN_;
1233 A.diag.assign((std::size_t)amgGlobalN_, 0.0);
1234 for (std::size_t r = 0; r < ggid.size(); ++r)
1235 A.diag[(std::size_t)ggid[r]] = gdiag[r];
1236 std::vector<std::vector<std::pair<int, double>>> rows((std::size_t)amgGlobalN_);
1237 for (std::size_t e = 0; e < grow.size(); ++e)
1238 rows[(std::size_t)grow[e]].push_back({gcol[e], gval[e]});
1239 A.start.assign((std::size_t)amgGlobalN_ + 1, 0);
1240 for (int r = 0; r < amgGlobalN_; ++r)
1241 A.start[(std::size_t)r + 1] = A.start[(std::size_t)r] + (long)rows[(std::size_t)r].size();
1242 A.nbr.reserve(grow.size());
1243 A.coef.reserve(grow.size());
1244 for (int r = 0; r < amgGlobalN_; ++r)
1245 for (auto& [c, v] : rows[(std::size_t)r]) {
1246 A.nbr.push_back(c);
1247 A.coef.push_back(v);
1248 }
1249 // Singular (periodic / all-Neumann) path: every fluid diagonal is BY CONSTRUCTION the
1250 // negative sum of its off-diagonals (walls/solids contribute zero, and no Dirichlet anchor
1251 // exists when removeMean_). The float (MReal) level storage breaks that identity at ~5e-8
1252 // relative, which shifts the operator's near-null vector off the constant the null-space
1253 // projection assumes — measured as the inner CG flooring at ~1e-5 and burning its full
1254 // iteration cap every call. Resum the diagonal in double so A·1 = 0 EXACTLY per fluid row.
1255 if (removeMean_)
1256 for (int r = 0; r < amgGlobalN_; ++r)
1257 if (!amgSolid_[(std::size_t)r] && !rows[(std::size_t)r].empty()) {
1258 double s = 0;
1259 for (auto& [c, v] : rows[(std::size_t)r])
1260 s += v;
1261 A.diag[(std::size_t)r] = -s;
1262 }
1263 amgA_ = A;
1264 amg_ = std::make_shared<peclet::core::solver::GraphAMG>();
1265 amg_->build(A);
1266 }
1267 }
1268 // Solve the coarsest level with the agglomerated AMG, REDUNDANTLY: all-gather the coarse rhs,
1269 // every rank runs the identical GraphAMG-preconditioned CG on the identical global operator
1270 // (deterministic serial code on identical data => bit-identical solutions), and each extracts
1271 // its own block — one Allgatherv per V-cycle, no rank-0 serialization, no result broadcast.
1273 if (!amg_ && !distributed_)
1274 buildAmg(lv);
1275#ifdef PECLET_FLOW_MPI
1276 if (distributed_ && amgGlobalN_ == 0)
1277 buildAmg(lv);
1278#endif
1279 const int nx = lv.inner.x, ny = lv.inner.y, nz = lv.inner.z, ex = lv.ext.x, ey = lv.ext.y;
1280 const int g = lv.g;
1281 auto hrhs = Kokkos::create_mirror_view(lv.rhs);
1282 Kokkos::deep_copy(hrhs, lv.rhs);
1283 std::vector<double> lb;
1284 lb.reserve(amgGlobalOfLocal_.size());
1285 for (int k = 0; k < nz; ++k)
1286 for (int j = 0; j < ny; ++j)
1287 for (int i = 0; i < nx; ++i)
1288 lb.push_back((double)hrhs((long)(i + g) + (long)(j + g) * ex + (long)(k + g) * ex * ey));
1289 // all-gather rhs by global id -> b; every rank solves the identical problem.
1290 std::vector<double> z((std::size_t)std::max(amgGlobalN_, 1), 0.0);
1291#ifdef PECLET_FLOW_MPI
1292 if (distributed_) {
1293 std::vector<int> ggid;
1294 std::vector<double> gb;
1295 gatherv(amgGlobalOfLocal_, ggid);
1296 gatherv(lb, gb);
1297 std::vector<double> b((std::size_t)amgGlobalN_, 0.0);
1298 for (std::size_t r = 0; r < ggid.size(); ++r)
1299 b[(std::size_t)ggid[r]] = gb[r];
1300 pcgAmg(b, z);
1301 } else
1302#endif
1303 {
1304 std::vector<double> b(lb.begin(), lb.end());
1305 pcgAmg(b, z);
1306 }
1307 // scatter z[gid] back into this rank's inner cells.
1308 auto hx = Kokkos::create_mirror_view(lv.x);
1309 Kokkos::deep_copy(hx, 0.0);
1310 std::size_t c = 0;
1311 for (int k = 0; k < nz; ++k)
1312 for (int j = 0; j < ny; ++j)
1313 for (int i = 0; i < nx; ++i)
1314 hx((long)(i + g) + (long)(j + g) * ex + (long)(k + g) * ex * ey) =
1315 z[(std::size_t)amgGlobalOfLocal_[c++]];
1316 Kokkos::deep_copy(lv.x, hx);
1317 if (agmgDebug() && !distributed_) {
1318 // Consistency check: the CSR solution must satisfy the V-cycle's OWN bottom operator
1319 // (ghost fill + outflow ghost + 7-point apply). A large residual here means buildAmg
1320 // assembled a DIFFERENT matrix than the one the hierarchy applies.
1321 fill(lv, lv.x);
1322 applyOutflowGhost(lv.ext, lv.x, lv.g);
1323 residualCutcell(lv.res, CCConst(lv.x), CCConst(lv.rhs), FPC(lv.AC), FPC(lv.AW), FPC(lv.AE),
1324 FPC(lv.AS), FPC(lv.AN), FPC(lv.AB), FPC(lv.AT), lv.ext, lv.g);
1325 const double rn = maxabs(lv, lv.res);
1326 auto hb = Kokkos::create_mirror_view(lv.rhs);
1327 Kokkos::deep_copy(hb, lv.rhs);
1328 double bn = 0;
1329 for (std::size_t i = 0; i < hb.size(); ++i)
1330 bn = std::max(bn, std::fabs((double)hb(i)));
1331 printf("[agmg] vcycle-op residual of CSR solution: max|b-Ax|=%.3e max|b|=%.3e rel=%.3e\n",
1332 rn, bn, bn > 0 ? rn / bn : 0.0);
1333 fflush(stdout);
1334 }
1335 }
1336 // GraphAMG-preconditioned CG on the global bottom operator. For the periodic/all-Neumann case the
1337 // operator is singular (constant null space) and the mean must be projected out of the rhs and
1338 // the preconditioned residual (compatibility). With a Dirichlet outflow (removeMean_ == false)
1339 // the operator is NON-singular and the projection must be SKIPPED — removing the constant from a
1340 // non-singular system returns a wrong bottom correction and the V-cycle around it diverges.
1341 // Runs on rank 0 only.
1342 void pcgAmg(std::vector<double>& b, std::vector<double>& x) {
1343 const std::size_t n = (std::size_t)amgGlobalN_;
1344 const int dbg = agmgDebug();
1345 double bSolidMax = 0, bFluidMax = 0, bFluidMean = 0, bAllMean = 0;
1346 if (dbg) { // rhs anatomy BEFORE the null-space projection (b is gid-ordered)
1347 long nf = 0;
1348 double sf = 0, sa = 0;
1349 for (std::size_t i = 0; i < n; ++i) {
1350 sa += b[i];
1351 if (i < amgSolid_.size() && amgSolid_[i])
1352 bSolidMax = std::max(bSolidMax, std::fabs(b[i]));
1353 else {
1354 bFluidMax = std::max(bFluidMax, std::fabs(b[i]));
1355 sf += b[i];
1356 ++nf;
1357 }
1358 }
1359 bFluidMean = nf ? sf / (double)nf : 0.0;
1360 bAllMean = n ? sa / (double)n : 0.0;
1361 }
1362 double bCompMax = 0; // max per-component |sum(b)| / |b|_max: the per-pocket incompatibility
1363 if (dbg && amgNComp_ > 1) {
1364 std::vector<double> cs((std::size_t)amgNComp_, 0.0);
1365 for (std::size_t i = 0; i < n; ++i)
1366 if (amgComp_[i] >= 0)
1367 cs[(std::size_t)amgComp_[i]] += b[i];
1368 for (double s : cs)
1369 bCompMax = std::max(bCompMax, std::fabs(s));
1370 bCompMax /= (bFluidMax > 0 ? bFluidMax : 1.0);
1371 }
1372 auto meanZero = [&](std::vector<double>& v) {
1373 if (!removeMean_)
1374 return; // Dirichlet-anchored (outflow) operator: non-singular, no null space to project
1375 // The null space is one constant PER CONNECTED FLUID COMPONENT — solid cells are identity
1376 // rows (non-singular) and a coarse level can pinch fluid off into pockets, each carrying its
1377 // own constant. Projecting the ALL-cell mean out instead (the old code) both leaves null
1378 // components alive and writes a spurious value onto every solid coordinate; the next matvec
1379 // (identity rows) feeds that back into the residual, the effective preconditioner turns
1380 // nonsymmetric, and the inner CG stalls at its iteration cap (measured on random_spheres:
1381 // every bottom solve capped at 200 with relres up to ~1, +41% outer iterations).
1382 if (amgNComp_ <= 0)
1383 return;
1384 std::vector<double> m((std::size_t)amgNComp_, 0.0);
1385 std::vector<long> cnt((std::size_t)amgNComp_, 0);
1386 for (std::size_t i = 0; i < n; ++i)
1387 if (amgComp_[i] >= 0) {
1388 m[(std::size_t)amgComp_[i]] += v[i];
1389 ++cnt[(std::size_t)amgComp_[i]];
1390 }
1391 for (std::size_t c = 0; c < m.size(); ++c)
1392 m[c] = cnt[c] ? m[c] / (double)cnt[c] : 0.0;
1393 for (std::size_t i = 0; i < n; ++i)
1394 if (amgComp_[i] >= 0)
1395 v[i] -= m[(std::size_t)amgComp_[i]];
1396 };
1397 meanZero(b);
1398 x.assign(n, 0.0);
1399 std::vector<double> r = b, z(n), p(n), Ap(n);
1400 amg_->apply(r, z);
1401 meanZero(z);
1402 p = z;
1403 auto dot = [&](const std::vector<double>& a, const std::vector<double>& c) {
1404 double s = 0;
1405 for (std::size_t i = 0; i < n; ++i)
1406 s += a[i] * c[i];
1407 return s;
1408 };
1409 double rz = dot(r, z), r0 = std::sqrt(dot(r, r));
1410 // 1e-8 is deliberate: the V-cycle's outer iteration count is unchanged from a far looser
1411 // bottom (measured), while 1e-10 sits at/below the double-precision floor of this
1412 // projected solve (rounding of the per-iteration null-space projection), where CG grinds
1413 // out its full iteration cap for nothing.
1414 int it = 0;
1415 for (; it < 100 && r0 > 0; ++it) {
1416 amgA_.apply(p, Ap);
1417 const double a = rz / dot(p, Ap);
1418 for (std::size_t i = 0; i < n; ++i) {
1419 x[i] += a * p[i];
1420 r[i] -= a * Ap[i];
1421 }
1422 if (std::sqrt(dot(r, r)) <= 1e-8 * r0)
1423 break;
1424 amg_->apply(r, z);
1425 meanZero(z);
1426 const double rzn = dot(r, z);
1427 const double beta = rzn / rz;
1428 rz = rzn;
1429 for (std::size_t i = 0; i < n; ++i)
1430 p[i] = z[i] + beta * p[i];
1431 }
1432 meanZero(x);
1433 if (dbg) {
1434 double xSolidMax = 0, xFluidMax = 0;
1435 for (std::size_t i = 0; i < n; ++i)
1436 if (i < amgSolid_.size() && amgSolid_[i])
1437 xSolidMax = std::max(xSolidMax, std::fabs(x[i]));
1438 else
1439 xFluidMax = std::max(xFluidMax, std::fabs(x[i]));
1440 const double rn = std::sqrt(dot(r, r));
1441 ++agmgCalls_;
1442 if (dbg >= 2 || agmgCalls_ <= 60 || it >= 100 || agmgCalls_ % 50 == 0) {
1443 printf("[agmg] call=%ld iters=%d relres=%.2e |b|sol=%.3e |b|fl=%.3e "
1444 "mean(b) fl=%.3e all=%.3e compat=%.2e |x|sol=%.3e |x|fl=%.3e%s\n",
1445 agmgCalls_, it, r0 > 0 ? rn / r0 : 0.0, bSolidMax, bFluidMax, bFluidMean, bAllMean,
1446 bCompMax, xSolidMax, xFluidMax, it >= 100 ? " CAP" : "");
1447 fflush(stdout);
1448 }
1449 }
1450 }
1451#ifdef PECLET_FLOW_MPI
1452 template <class T>
1453 void gatherv(const std::vector<T>& local, std::vector<T>& all) {
1454 // REDUNDANT agglomeration: every rank receives the full concatenation (rank order, so the
1455 // assembled coarse problem is bit-identical on all ranks and each solves it locally with no
1456 // rank-0 bottleneck and no result broadcast).
1457 int size = 1;
1458 MPI_Comm_size(comm_, &size);
1459 const int lbytes = (int)(local.size() * sizeof(T));
1460 std::vector<int> bc(size), bd(size, 0);
1461 MPI_Allgather(&lbytes, 1, MPI_INT, bc.data(), 1, MPI_INT, comm_);
1462 int tot = 0;
1463 for (int r = 0; r < size; ++r) {
1464 bd[r] = tot;
1465 tot += bc[r];
1466 }
1467 all.resize((std::size_t)tot / sizeof(T));
1468 MPI_Allgatherv(local.data(), lbytes, MPI_BYTE, all.data(), bc.data(), bd.data(), MPI_BYTE,
1469 comm_);
1470 }
1471#endif
1472
1473 // Level-0 matvec with the halo overlapped: post the exchange, apply the interior rows while the
1474 // messages are in flight, land the ghosts (+ outflow ghost), apply the boundary shell. Reads v,
1475 // writes y (no aliasing) => bit-identical to the blocking fill-then-apply. Single-rank: the
1476 // blocking path.
1478#ifdef PECLET_FLOW_MPI
1479 if (distributed_) {
1480 const C3 lo{G + 1, G + 1, G + 1};
1481 const C3 hi{l0.ext.x - G - 1, l0.ext.y - G - 1, l0.ext.z - G - 1};
1482 l0.dev->exchangeBegin(v);
1483 applyCutcellOpBox(y, CCConst(v), FPC(l0.AC), FPC(l0.AW), FPC(l0.AE), FPC(l0.AS), FPC(l0.AN),
1484 FPC(l0.AB), FPC(l0.AT), l0.ext, lo, hi, C3{0, 0, 0}, C3{0, 0, 0});
1485 l0.dev->exchangeEnd(v);
1486 applyOutflowGhost(l0.ext, v);
1487 applyCutcellOpBox(y, CCConst(v), FPC(l0.AC), FPC(l0.AW), FPC(l0.AE), FPC(l0.AS), FPC(l0.AN),
1488 FPC(l0.AB), FPC(l0.AT), l0.ext, C3{G, G, G},
1489 C3{l0.ext.x - G, l0.ext.y - G, l0.ext.z - G}, lo, hi);
1490 return;
1491 }
1492#endif
1493 fill(l0, v);
1494 applyOutflowGhost(l0.ext, v);
1495 applyCutcellOp(y, CCConst(v), FPC(l0.AC), FPC(l0.AW), FPC(l0.AE), FPC(l0.AS), FPC(l0.AN),
1496 FPC(l0.AB), FPC(l0.AT), l0.ext, G);
1497 }
1498 // periodic ghost fill (3 axes) of a level-sized field / the openness triple. Distributed: the
1499 // per-level core halo (cross-rank + periodic in one call).
1500 void fill(Level& lv, CCField f) {
1501#ifdef PECLET_FLOW_MPI
1502 if (distributed_) {
1503 lv.dev->exchange(f);
1504 return;
1505 }
1506#endif
1507 fillAxis(lv, f, 0);
1508 fillAxis(lv, f, 1);
1509 fillAxis(lv, f, 2);
1510 }
1512 fill(lv, lv.ox);
1513 fill(lv, lv.oy);
1514 fill(lv, lv.oz);
1515 }
1516 void fillAxis(Level& lv, CCField f, int axis) {
1517 CCExec space;
1518 C3 e = lv.ext;
1519 const int G = lv.g; // shadows the class constant: this level's ghost width
1520 int N3[3] = {lv.inner.x, lv.inner.y, lv.inner.z};
1521 int dims[3] = {e.x, e.y, e.z};
1522 long st[3] = {1, e.x, (long)e.x * e.y};
1523 const int a = axis, b = (axis + 1) % 3, c = (axis + 2) % 3;
1524 const long sa = st[a], sb = st[b], sc = st[c];
1525 const int N = N3[a];
1526 CCField ff = f;
1527 Kokkos::parallel_for(
1528 "peclet::flow::mg_pfill",
1529 Kokkos::MDRangePolicy<CCExec, Kokkos::Rank<2>>(space, {0, 0}, {dims[b], dims[c]}),
1530 KOKKOS_LAMBDA(int p0, int p1) {
1531 const long base = (long)p0 * sb + (long)p1 * sc;
1532 for (int gl = 0; gl < G; ++gl) {
1533 ff(base + (long)gl * sa) = ff(base + (long)(gl + N) * sa);
1534 ff(base + (long)(G + N + gl) * sa) = ff(base + (long)(G + gl) * sa);
1535 }
1536 });
1537 }
1538#ifdef PECLET_FLOW_MPI
1539 // ghost-projection matvec staging (solveBiCGStab distributed): copy the l0 inner cells onto the
1540 // caller's g=2 block (whose halo then carries the overlay's +/-2 reach) ...
1541 void stageG2(Level& l0, CCField q, CCField xg2, C3 ext2) {
1542 CCExec space;
1543 const C3 e1 = l0.ext, nn = l0.inner;
1544 CCField dst = xg2, src = q;
1545 Kokkos::parallel_for(
1546 "peclet::flow::gp_stage_g2",
1547 Kokkos::MDRangePolicy<CCExec, Kokkos::Rank<3>>(space, {0, 0, 0}, {nn.x, nn.y, nn.z}),
1548 KOKKOS_LAMBDA(int x, int y, int z) {
1549 dst((long)(x + 2) + (long)(y + 2) * ext2.x + (long)(z + 2) * (long)ext2.x * ext2.y) =
1550 src((long)(x + G) + (long)(y + G) * e1.x + (long)(z + G) * (long)e1.x * e1.y);
1551 });
1552 }
1553 // ... and read the whole l0 block (inner + its g=1 ring) back from the exchanged g=2 copy, so
1554 // the 7-point op's halo is current without a second exchange.
1555 void unstageG2(Level& l0, CCField q, CCField xg2) {
1556 CCExec space;
1557 const C3 e1 = l0.ext;
1558 const C3 ext2{e1.x + 2, e1.y + 2, e1.z + 2}; // same inner, gb 1 -> 2
1559 CCField dst = q, src = xg2;
1560 Kokkos::parallel_for(
1561 "peclet::flow::gp_unstage_g2",
1562 Kokkos::MDRangePolicy<CCExec, Kokkos::Rank<3>>(space, {0, 0, 0}, {e1.x, e1.y, e1.z}),
1563 KOKKOS_LAMBDA(int x, int y, int z) {
1564 dst((long)x + (long)y * e1.x + (long)z * (long)e1.x * e1.y) =
1565 src((long)(x + 1) + (long)(y + 1) * ext2.x + (long)(z + 1) * (long)ext2.x * ext2.y);
1566 });
1567 }
1568#endif
1569 void axpy(CCField y, double a, CCField x) {
1570 CCExec space;
1571 CCField yy = y, xx = x;
1572 std::size_t n = y.extent(0);
1573 Kokkos::parallel_for(
1574 "mgaxpy", Kokkos::RangePolicy<CCExec>(space, 0, n),
1575 KOKKOS_LAMBDA(std::size_t i) { yy(i) += a * xx(i); });
1576 }
1577 void aypx(CCField y, double a, CCField x) {
1578 CCExec space;
1579 CCField yy = y, xx = x;
1580 std::size_t n = y.extent(0);
1581 Kokkos::parallel_for(
1582 "mgaypx", Kokkos::RangePolicy<CCExec>(space, 0, n),
1583 KOKKOS_LAMBDA(std::size_t i) { yy(i) = xx(i) + a * yy(i); });
1584 }
1585 void scale(CCField y, double a) {
1586 CCExec space;
1587 CCField yy = y;
1588 std::size_t n = y.extent(0);
1589 Kokkos::parallel_for(
1590 "mgscale", Kokkos::RangePolicy<CCExec>(space, 0, n),
1591 KOKKOS_LAMBDA(std::size_t i) { yy(i) *= a; });
1592 }
1593 void lin(CCField out, double a, CCField x, double b, CCField y) { // out = a*x + b*y (mg_lin_k)
1594 CCExec space;
1595 CCField oo = out, xx = x, yy = y;
1596 std::size_t n = out.extent(0);
1597 Kokkos::parallel_for(
1598 "mglin", Kokkos::RangePolicy<CCExec>(space, 0, n),
1599 KOKKOS_LAMBDA(std::size_t i) { oo(i) = a * xx(i) + b * yy(i); });
1600 }
1601 // zero the solid-cell entries (AC<=tiny) -> project out the solid null modes (mg_mask_solid_k).
1602 void maskSolid(Level& lv, CCField f) {
1603 CCExec space;
1604 CCField ff = f;
1605 FPV ac = lv.AC;
1606 std::size_t n = f.extent(0);
1607 Kokkos::parallel_for(
1608 "mgmasksolid", Kokkos::RangePolicy<CCExec>(space, 0, n), KOKKOS_LAMBDA(std::size_t i) {
1609 if (!(ac(i) > 1e-30f))
1610 ff(i) = 0.0;
1611 });
1612 }
1613
1614 // Estimate the spectral bounds [lmin,lmax] of M^{-1}A (M^{-1} = one symmetric V-cycle) by power
1615 // iteration (direct for the max + a shifted iteration for the min), seeded by `seed`.
1616 // Communication-heavy, so the CUDA driver runs it once on step 1 and reuses the bounds. Port of
1617 // estimate_eigenvalues.
1618 void estimateEigenvalues(CCConst seed, double& lmin, double& lmax, int iters, int pre, int post,
1619 int bottom) {
1620 pre_ = pre;
1621 post_ = post;
1622 bottom_ = bottom;
1623 Level& l0 = lv_[0];
1624 const std::size_t n = l0.n;
1625 CCField v("ev_v", n), w("ev_w", n), z("ev_z", n), srhs("ev_srhs", n);
1626 Kokkos::deep_copy(srhs, seed);
1627 auto matvec = [&](CCField y, CCField x) { matvecOverlap(l0, y, x); };
1628 auto applyT = [&](CCField out,
1629 CCField in) { // out = M^{-1} A in, projected onto the fluid range
1630 matvec(w, in);
1631 Kokkos::deep_copy(l0.rhs, w);
1632 Kokkos::deep_copy(l0.x, 0.0);
1633 vcycle(0, /*sym=*/true);
1634 Kokkos::deep_copy(out, l0.x);
1635 removeMean(l0, out);
1636 maskSolid(l0, out);
1637 };
1638 auto normalize = [&](CCField x) {
1639 double nr = std::sqrt(dot(l0, x, x));
1640 if (nr > 0)
1641 scale(x, 1.0 / nr);
1642 };
1643 auto seedf = [&](CCField x) {
1644 Kokkos::deep_copy(x, srhs);
1645 removeMean(l0, x);
1646 maskSolid(l0, x);
1647 normalize(x);
1648 };
1649 seedf(v);
1650 lmax = 1.0;
1651 for (int k = 0; k < iters; ++k) {
1652 applyT(z, v);
1653 lmax = dot(l0, v, z);
1654 Kokkos::deep_copy(v, z);
1655 normalize(v);
1656 }
1657 seedf(v);
1658 double mu = 0.0;
1659 for (int k = 0; k < iters; ++k) {
1660 applyT(z, v);
1661 lin(z, lmax, v, -1.0, z); // z = lmax*v - T v
1662 mu = dot(l0, v, z);
1663 Kokkos::deep_copy(v, z);
1664 normalize(v);
1665 }
1666 double e_hi = lmax, e_lo = lmax - mu; // direct (max) + shifted (min) Rayleigh estimates
1667 lmin = e_lo < e_hi ? e_lo : e_hi;
1668 lmax = e_lo < e_hi ? e_hi : e_lo; // robust min/max bracket
1669 if (lmin < 0.02 * lmax)
1670 lmin = 0.02 * lmax;
1671 }
1672
1673 // Chebyshev semi-iteration preconditioned by ONE symmetric V-cycle -- same goal as solvePCG but
1674 // the step coefficients come from the spectral bounds [a,b], so NO per-iteration global
1675 // dot-products (communication- light at scale). rhs on level-0 supplied as `b`; solution left in
1676 // `x`. Returns the V-cycle count. Port of solve_chebyshev.
1677 int solveChebyshev(CCField b, CCField x, int maxit, double rtol, int pre, int post, int bottom,
1678 double a, double bnd) {
1679 pre_ = pre;
1680 post_ = post;
1681 bottom_ = bottom;
1682 Level& l0 = lv_[0];
1683 const std::size_t n = l0.n;
1684 if (a > bnd) {
1685 double t = a;
1686 a = bnd;
1687 bnd = t;
1688 } // robust to swapped bounds
1689 a *= 0.95;
1690 bnd *= 1.05; // safety margin: [a,b] must bracket the spectrum
1691 CCField r("cb_r", n), z("cb_z", n), d("cb_d", n), w("cb_w", n);
1692 auto matvec = [&](CCField y, CCField v) { matvecOverlap(l0, y, v); };
1693 auto precond = [&](CCField zz, CCField rr) {
1694 Kokkos::deep_copy(l0.rhs, rr);
1695 Kokkos::deep_copy(l0.x, 0.0);
1696 vcycle(0, /*sym=*/true);
1697 Kokkos::deep_copy(zz, l0.x);
1698 };
1699 const double theta = 0.5 * (bnd + a), delta = 0.5 * (bnd - a), sigma1 = theta / delta;
1700 double rho = 1.0 / sigma1;
1701 matvec(w, x); // r = b - A x
1702 Kokkos::deep_copy(r, b);
1703 axpy(r, -1.0, w);
1704 removeMean(l0, r);
1705 const double r0 = maxabs(l0, r);
1706 int nvc = 0;
1707 if (r0 > 0.0) {
1708 precond(z, r);
1709 ++nvc; // z = M^{-1} r
1710 lin(d, 1.0 / theta, z, 0.0, z);
1711 axpy(x, 1.0, d); // d = z/theta; x += d
1712 for (int i = 1; i < maxit; ++i) {
1713 matvec(w, d);
1714 axpy(r, -1.0, w);
1715 removeMean(l0, r); // r -= A d
1716 if (maxabs(l0, r) < rtol * r0)
1717 break;
1718 precond(z, r);
1719 ++nvc;
1720 const double rho_new = 1.0 / (2.0 * sigma1 - rho);
1721 lin(d, rho_new * rho, d, 2.0 * rho_new / delta, z);
1722 axpy(x, 1.0, d); // d update; x += d
1723 rho = rho_new;
1724 }
1725 }
1726 removeMean(l0, x);
1727 return nvc;
1728 }
1729 // reductions / mean removal over inner FLUID cells (AC>tiny) of a level.
1730 double dot(Level& lv, CCField a, CCField b) {
1731 CCExec space;
1732 C3 e = lv.ext;
1733 const int g = lv.g;
1734 CCField aa = a, bb = b;
1735 FPV ac = lv.AC;
1736 double s = 0;
1737 ccReduce3(
1738 "mgdot", C3{g, g, g}, C3{e.x - g, e.y - g, e.z - g},
1739 KOKKOS_LAMBDA(int x, int y, int z, double& acc) {
1740 const long i = (long)x + (long)y * e.x + (long)z * (long)e.x * e.y;
1741 if (ac(i) > 1e-30f)
1742 acc += aa(i) * bb(i);
1743 },
1744 s);
1745 return allreduce(s, MPI_SUM_);
1746 }
1747 double maxabs(Level& lv, CCField a) {
1748 CCExec space;
1749 C3 e = lv.ext;
1750 const int g = lv.g;
1751 CCField aa = a;
1752 FPV ac = lv.AC;
1753 double m = 0;
1754 ccReduce3(
1755 "mgmax", C3{g, g, g}, C3{e.x - g, e.y - g, e.z - g},
1756 KOKKOS_LAMBDA(int x, int y, int z, double& acc) {
1757 const long i = (long)x + (long)y * e.x + (long)z * (long)e.x * e.y;
1758 if (ac(i) > 1e-30f) {
1759 const double v = Kokkos::fabs(aa(i));
1760 if (v > acc)
1761 acc = v;
1762 }
1763 },
1764 Kokkos::Max<double>(m));
1765 return allreduce(m, MPI_MAX_);
1766 }
1767 void removeMean(Level& lv, CCField f) {
1768 if (!removeMean_)
1769 return; // non-singular operator (Dirichlet outflow present) -> no null-space projection
1770 CCExec space;
1771 C3 e = lv.ext;
1772 const int g = lv.g;
1773 CCField ff = f;
1774 FPV ac = lv.AC;
1775 double sum = 0;
1776 long cnt = 0;
1777 Kokkos::parallel_reduce(
1778 "mgmeanr",
1779 Kokkos::MDRangePolicy<CCExec, Kokkos::Rank<3>>(space, {g, g, g},
1780 {e.x - g, e.y - g, e.z - g}),
1781 KOKKOS_LAMBDA(int x, int y, int z, double& s, long& k) {
1782 const long i = (long)x + (long)y * e.x + (long)z * (long)e.x * e.y;
1783 if (ac(i) > 1e-30f) {
1784 s += ff(i);
1785 k += 1;
1786 }
1787 },
1788 sum, cnt);
1789 double dcnt = (double)cnt;
1790 allreduceSum2(sum, dcnt); // ONE latency hit for the {sum, count} pair (was two)
1791 cnt = (long)dcnt;
1792 if (cnt == 0)
1793 return;
1794 const double mean = sum / (double)cnt;
1795 Kokkos::parallel_for(
1796 "mgmeans",
1797 Kokkos::MDRangePolicy<CCExec, Kokkos::Rank<3>>(space, {g, g, g},
1798 {e.x - g, e.y - g, e.z - g}),
1799 KOKKOS_LAMBDA(int x, int y, int z) {
1800 const long i = (long)x + (long)y * e.x + (long)z * (long)e.x * e.y;
1801 if (ac(i) > 1e-30f)
1802 ff(i) -= mean;
1803 });
1804 }
1805
1806 // Mean-removal scope. "all" (legacy): project the nullspace out at every V-cycle level,
1807 // after every matvec and on every residual update — ~10 extra MPI_Allreduce latency hits per
1808 // Krylov iteration whose only role is FP hygiene. "fine" (DEFAULT) keeps the removals that carry the
1809 // algorithm (the rhs/residual projections + the fine-level V-cycle exit + the final iterate) and
1810 // drops the interior-level ones: A maps mean-free vectors to mean-free vectors, so the Krylov
1811 // space never sees the dropped components (they lie in the nullspace and are removed from the
1812 // final x). Validated by iteration-count parity; not bit-identical to "all".
1813 void setMeanRemovalScope(bool all) { meanRemovalAll_ = all; }
1814
1815 // Accumulated wall time / call count of the global reductions (every dot product, residual max
1816 // and mean-removal funnels through allreduce()). This is THE latency-bound term of the
1817 // distributed pressure solve; the solver resets it per step and exposes it to Python.
1818 double allreduceSeconds() const { return allreduceTime_; }
1819 long allreduceCount() const { return allreduceCount_; }
1821 allreduceTime_ = 0.0;
1822 allreduceCount_ = 0;
1823 }
1824
1825 private:
1826 enum AllOp { kSum, kMax };
1827 // Global reduction over ranks (no-op single-rank / non-distributed -> byte-identical to the local
1828 // reduce).
1829 double allreduce(double v, AllOp op) {
1830#ifdef PECLET_FLOW_MPI
1831 if (distributed_) {
1832 const auto t0 = std::chrono::steady_clock::now();
1833 double g = 0;
1834 MPI_Allreduce(&v, &g, 1, MPI_DOUBLE, op == kSum ? MPI_SUM : MPI_MAX, comm_);
1835 allreduceTime_ += std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count();
1836 ++allreduceCount_;
1837 return g;
1838 }
1839#endif
1840 (void)op;
1841 return v;
1842 }
1843 // One MPI_Allreduce of a {sum, count} pair — elementwise MPI_SUM on a 2-vector is bit-identical
1844 // to two separate allreduces, at half the latency hits.
1845 void allreduceSum2(double& a, double& b) {
1846#ifdef PECLET_FLOW_MPI
1847 if (distributed_) {
1848 const auto t0 = std::chrono::steady_clock::now();
1849 double v[2] = {a, b}, g[2] = {0.0, 0.0};
1851 allreduceTime_ += std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count();
1852 ++allreduceCount_;
1853 a = g[0];
1854 b = g[1];
1855 }
1856#endif
1857 }
1858 static constexpr AllOp MPI_SUM_ = kSum, MPI_MAX_ = kMax;
1859
1860 std::vector<Level> lv_;
1861 int pre_ = 2, post_ = 2, bottom_ = 4;
1862 int bc_[6] = {0, 0, 0, 0, 0, 0};
1863 bool hasBC_ = false, removeMean_ = true, hasOutflow_ = false;
1864 // Default "fine" (measured winner of the at-scale ablation, Snellius H100 8+16 GPUs: 5.5%
1865 // faster than "all" with identical iteration counts; single-rank the reductions are free either
1866 // way). setMeanRemovalScope(true) restores the legacy every-level scope.
1867 bool meanRemovalAll_ = false;
1868 bool distributed_ = false;
1869 int dbgSolve_ = 0; // solve counter for the env-gated convergence trace (mgDebugLevel() >= 2)
1870 std::vector<double> lvTime_; // per-level V-cycle wall time (mgDebugLevel() >= 3)
1871 int lvCycles_ = 0;
1872 // Halo refresh before the V-cycle's residual (see vcycle). ON by default — the legacy
1873 // stale-ghost residual is kept behind PECLET_FLOW_MG_RESFILL=0 purely as a benchmark ablation.
1874 bool resFill_ = [] {
1875 const char* e = std::getenv("PECLET_FLOW_MG_RESFILL");
1876 return !e || std::atoi(e) != 0;
1877 }();
1878 double allreduceTime_ = 0.0;
1879 long allreduceCount_ = 0;
1880 // --- decomposition-agnostic algebraic bottom solve (GraphAMG) ---
1881 // The geometric coarse hierarchy needs a cleanly-coarsening (equal-weight) ORB. Under a WEIGHTED
1882 // decomposition the coarse levels misalign, so the multilevel path is unavailable and only pure
1883 // RB-GS (nLevels==1) works. With this enabled, the coarsest level is solved by an AGGLOMERATED
1884 // algebraic multigrid: the operator + rhs of the coarsest level are gathered to rank 0, solved by
1885 // a mesh-agnostic smoothed-aggregation AMG (core::solver::GraphAMG, exact by construction on any
1886 // decomposition), and the solution scattered back. With nLevels==1 this makes the whole pressure
1887 // solve mesh-independent AND decomposition-agnostic.
1888 int agglomMode_ = 0; // 0 smoothed bottom (default), -1 auto (see agglomerateBottom), 1 always
1889 int gnxF_ = 0, gnyF_ = 0, gnzF_ = 0; // GLOBAL fine dims (== local single-rank)
1890 mutable std::shared_ptr<peclet::core::solver::GraphAMG>
1891 amg_; // built once from the bottom operator
1892 mutable peclet::core::solver::HostCsrOp
1893 amgA_; // rank 0: the assembled global bottom operator (CG matvec)
1894 mutable std::vector<int> amgOwnerCount_; // rank 0: #bottom cells each rank owns (gather layout)
1895 mutable std::vector<int>
1896 amgGlobalOfLocal_; // this rank's bottom inner cells -> global bottom index
1897 mutable int amgGlobalN_ = 0; // total bottom global cells (rank 0)
1898 // PECLET_FLOW_AGMG_DEBUG instrumentation (see agmgDebug()): per-gid solid marker + call counter.
1899 mutable std::vector<std::uint8_t> amgSolid_;
1900 mutable std::vector<int> amgComp_; // fluid component id per gid (-1 = solid identity row)
1901 mutable int amgNComp_ = 0; // number of fluid components (null-space dimension)
1902 mutable long agmgCalls_ = 0;
1903 static int agmgDebug() {
1904 static const int v = [] {
1905 const char* e = std::getenv("PECLET_FLOW_AGMG_DEBUG");
1906 return e ? std::atoi(e) : 0;
1907 }();
1908 return v;
1909 }
1910#ifdef PECLET_FLOW_MPI
1912#endif
1913
1914 public:
1915 // Enable the agglomerated GraphAMG bottom solve (decomposition-agnostic multigrid coarse solve).
1916 // Rebuilds lazily on the next solve. Safe single-rank (local assemble + serial AMG).
1917 void setAgglomerationMode(int mode) { agglomMode_ = mode; } // -1 auto, 0 never, 1 always
1918 int agglomerationMode() const { return agglomMode_; }
1920 agglomMode_ = on ? 1 : 0;
1921 amg_.reset();
1922 }
1923
1924 private:
1925};
1926
1927} // namespace peclet::flow
1928
1929#endif // PECLET_FLOW_MAC_CUTCELL_MG_HPP
void axpy(CCField y, double a, CCField x)
void scale(CCField y, double a)
int solveChebyshev(CCField b, CCField x, int maxit, double rtol, int pre, int post, int bottom, double a, double bnd)
void aypx(CCField y, double a, CCField x)
void fillAxis(Level &lv, CCField f, int axis)
int solvePCG(CCField b, CCField x, CCField r, CCField p, CCField z, CCField Ap, int maxit, double rtol, int pre, int post, int bottom, const StarOverlay *star=nullptr, int nStar=0, C3 nnStar=C3{0, 0, 0})
void matvecOverlap(Level &l0, CCField y, CCField v)
static C3 parityOg(const Level &lv)
void vcycle(int L, bool sym)
void setOpenness(CCConst ox, CCConst oy, CCConst oz, double idx2, double idy2, double idz2)
void pcgAmg(std::vector< double > &b, std::vector< double > &x)
void setBoundaryConditions(const int bc[6])
double maxabs(Level &lv, CCField a)
void vcycleImpl(int L, bool sym)
static constexpr int G
void maskSolid(Level &lv, CCField f)
void lin(CCField out, double a, CCField x, double b, CCField y)
double dot(Level &lv, CCField a, CCField b)
void fill(Level &lv, CCField f)
void removeMean(Level &lv, CCField f)
void applyBoundaryOpenness(Level &lv)
void graphAmgSolveBottom(Level &lv)
void setAgglomerationMode(int mode)
void smooth(Level &lv, int sweeps, bool reverse)
void applyOutflowGhost(C3 ext, CCField x, int g=G)
int solveBiCGStab(CCField b, CCField x, CCField r, CCField rh, CCField p, CCField v, CCField t, CCField z, CCField z2, int maxit, double rtol, int pre, int post, int bottom, const GpOverlay &ov, int nOv, C3 nn)
void init(int nx, int ny, int nz, int nLevels)
bool caSmooth(const Level &lv) const
void setMeanRemovalScope(bool all)
void estimateEigenvalues(CCConst seed, double &lmin, double &lmax, int iters, int pre, int post, int bottom)
flow — directional ghost-cell IBM projection overlay (experimental second staggered IBM).
flow — portable (Kokkos) native per-face domain boundary conditions for the MAC grid.
flow — portable (Kokkos) cut-cell pressure operator + Chorin projection.
void bcSetOpenness(BField oa, B3 ext, int g, int a, int s, double val)
Definition mac_bc.hpp:234
void gpApplyDelta(CCField y, CCConst x, const GpOverlay &ov, int nOv, C3 nn, C3 extY, int gbY, C3 extX, int gbX, bool useGhost=false)
Overlay matvec correction: y(r) = rho_r * (y(r) + closure-face phi terms), where y currently holds th...
Kokkos::View< const MReal *, CCMem > FPC
void prolongAdd(CCField fine, CCConst coarse, C3 fext, C3 cext, int gf, int gc, C3 finner, C3 ratio)
void bcZeroPressureGhost(BField phi, B3 ext, int g, int a, int s)
Definition mac_bc.hpp:193
void coarsenOpenAvg(CCField oxc, CCField oyc, CCField ozc, CCConst oxf, CCConst oyf, CCConst ozf, C3 cext, C3 fext, int gc, int gf, C3 cinner, C3 ratio)
void residualCutcell(CCField r, CCConst x, CCConst b, FPC AC, FPC AW, FPC AE, FPC AS, FPC AN, FPC AB, FPC AT, C3 e, int g)
void starApplyDelta(CCField y, CCConst x, const StarOverlay &ov, int nOv, C3 nn, C3 extY, int gY, C3 extX, int gX)
y += S_star x over the inner cells of the (extY, gY) block, x read from the (extX,...
void cutcellSmoothColorBox(CCField phi, CCConst b, OpV AC, OpV AW, OpV AE, OpV AS, OpV AN, OpV AB, OpV AT, C3 e, C3 og, int color, C3 rlo, C3 rhi, C3 slo, C3 shi)
void applyCutcellOp(CCField y, CCConst x, OpV AC, OpV AW, OpV AE, OpV AS, OpV AN, OpV AB, OpV AT, C3 e, int g)
void ccReduce3(const char *name, C3 lo, C3 hi, F f, R &&reducer)
void residualCutcellBox(CCField r, CCConst x, CCConst b, FPC AC, FPC AW, FPC AE, FPC AS, FPC AN, FPC AB, FPC AT, C3 e, C3 rlo, C3 rhi, C3 slo, C3 shi)
void ccFor3(const char *name, C3 lo, C3 hi, F f)
void ibmFillEntry(const OV &o, int list_idx, int c_idx, float sdf_c, const float sdf_n[6], int bc_type, const float *thEx)
void buildCutcellOp(OpV AC, OpV AW, OpV AE, OpV AS, OpV AN, OpV AB, OpV AT, CCConst ox, CCConst oy, CCConst oz, C3 e, int g, double gfx, double gfy, double gfz)
Kokkos::View< double *, CCMem > CCField
void restrictAvg(CCField coarse, CCConst fine, C3 cext, C3 fext, int gc, int gf, C3 cinner, C3 ratio)
Kokkos::View< MReal *, CCMem > FPV
void cutcellSmoothColor(CCField phi, CCConst b, OpV AC, OpV AW, OpV AE, OpV AS, OpV AN, OpV AB, OpV AT, C3 e, C3 og, int g, int color)
Kokkos::DefaultExecutionSpace CCExec
void applyCutcellOpBox(CCField y, CCConst x, OpV AC, OpV AW, OpV AE, OpV AS, OpV AN, OpV AB, OpV AT, C3 e, C3 rlo, C3 rhi, C3 slo, C3 shi)
Kokkos::View< const double *, CCMem > CCConst
flow — Design B of the fluid-only collocated constraint (route 2b): Kron (star-mesh) elimination of s...
std::unique_ptr< GridHalo< double > > dev
std::unique_ptr< GridHaloTopology< kDim > > halo
One entry per eliminated solid-centered cell: packed INNER flat index + the apertures of its (up to 6...
static constexpr double AC
static constexpr int N
static constexpr double F