flow 1.3.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 <cassert>
18#include <chrono>
19#include <cmath>
20#include <cstdlib>
21#include <Kokkos_Core.hpp>
22#include <limits>
23#include <memory>
24#include <stdexcept>
25#include <string>
26#include <vector>
27
28#include "ghost_projection.hpp" // GpOverlay + gpApplyDelta (ghost-projection BiCGStab matvec)
29#include "mac_bc.hpp"
30#include "mac_pressure.hpp"
31#include "mg_bottom_direct.hpp" // §13: the direct preconditioner of the device bottom
32#include "peclet/core/solver/graph_amg.hpp" // decomposition-agnostic algebraic bottom solve
33#include "star_elimination.hpp" // StarOverlay + starApplyDelta (mode-B fluid-only PCG matvec)
34
35// Multi-rank (MPI) path is opt-in: the single-GPU module never links MPI, so all distributed code
36// is gated (mirrors the CUDA PECLET_FLOW_BUILD_MPI gating). When PECLET_FLOW_MPI is off, CutcellMG
37// is byte-identical to before.
38#ifdef PECLET_FLOW_MPI
39#include <memory>
40#include <optional>
41#include <stdexcept>
42
43#include "peclet/core/decomp/block_decomposer.hpp"
44#include "peclet/core/decomp/grid_redistribute.hpp"
45#include "peclet/core/decomp/redistribute_topology.hpp"
46#include "peclet/core/decomp/stage_comm.hpp"
47#include "peclet/core/decomp/stage_target.hpp"
48#include "peclet/core/halo/grid_halo.hpp"
49#include "peclet/core/halo/grid_halo_topology.hpp"
50#include "policy.hpp"
51#endif
52
53namespace peclet::flow {
54
55#ifdef PECLET_FLOW_MPI
56using peclet::core::halo::GridHalo;
57using peclet::core::halo::GridHaloTopology;
58#endif
59
60// Operator/level storage precision. Float (the CUDA-era default) breaks the singular row-sum
61// identity A*1=0 at ~eps_f relative per row; under high face-coefficient contrast (order-2
62// apertures span 3 decades) the defect on mixed large+tiny rows is amplified to ~eps_f*contrast,
63// which perturbs the near-null vector the mean-removal deflation assumes and floors/rebounds the
64// CG-family drivers near r/r0 ~ 1e-6 (see doc/collocated_paper_plan.md row 55). The bottom AMG
65// already re-sums its diagonal in double for exactly this reason. -DPECLET_FLOW_OPERATOR_DOUBLE
66// switches the whole hierarchy to double (A/B instrument; ~2x operator memory).
67//
68// EVERY store into an FPV/FV must go through MReal, or the switch is silently partial. WO-M found
69// three families of hard `(float)` casts that survived the templating and clamped their operator to
70// float even in a double build: `IbmSolver::buildAdvStencil` / `buildAdvStencilVar` (the implicit-
71// FOU momentum stencil), `IbmSolver::addDragDiagonal` (the CFD-DEM face drag — its symptom was a
72// porous steady drag balance stuck at 4.8e-8 in BOTH builds), and five sites in
73// `mac_velocity_mg.hpp` (the velocity-MG staircase / upwind-coarse / const-aniso operators and the
74// no-slip fold). They now cast to MReal, which is byte-identical when MReal is float.
75#ifdef PECLET_FLOW_OPERATOR_DOUBLE
76using MReal = double;
77#else
78using MReal = float; // operator storage = CUDA mreal
79#endif
80using FPV = Kokkos::View<MReal*, CCMem>;
81using FPC = Kokkos::View<const MReal*, CCMem>;
82
83// coarsen staggered face openness: each coarse face = average of the ratio_b*ratio_c fine sub-faces
84// it spans (mg_coarsen_open_avg_k port). gc/gf: coarse/fine block ghost widths (they can differ —
85// CA-eligible coarse levels carry g=2). The cell body is the A0 form (§5.2).
86template <class CV, class FV>
88 const FV& oxf, const FV& oyf, const FV& ozf, C3 cext,
89 C3 fext, int gc, int gf, C3 ratio, int icx, int icy,
90 int icz) {
91 const int rx = ratio.x, ry = ratio.y, rz = ratio.z;
92 const int fx0 = rx * icx + gf, fy0 = ry * icy + gf, fz0 = rz * icz + gf;
93 const long fsy = fext.x, fsz = (long)fext.x * fext.y;
94 auto F = [&](const FV& T, int x, int y, int z) {
95 return T((long)x + (long)y * fsy + (long)z * fsz);
96 };
97 double sx = 0, sy = 0, sz = 0;
98 for (int a = 0; a < ry; ++a)
99 for (int b = 0; b < rz; ++b)
100 sx += F(oxf, fx0, fy0 + a, fz0 + b);
101 for (int a = 0; a < rx; ++a)
102 for (int b = 0; b < rz; ++b)
103 sy += F(oyf, fx0 + a, fy0, fz0 + b);
104 for (int a = 0; a < rx; ++a)
105 for (int b = 0; b < ry; ++b)
106 sz += F(ozf, fx0 + a, fy0 + b, fz0);
107 const long ci =
108 (long)(icx + gc) + (long)(icy + gc) * cext.x + (long)(icz + gc) * (long)cext.x * cext.y;
109 oxc(ci) = sx / (double)(ry * rz);
110 oyc(ci) = sy / (double)(rx * rz);
111 ozc(ci) = sz / (double)(rx * ry);
112}
114 CCConst ozf, C3 cext, C3 fext, int gc, int gf, C3 cinner, C3 ratio) {
115 ccFor3(
116 "peclet::flow::coarsen_open", C3{0, 0, 0}, C3{cinner.x, cinner.y, cinner.z},
117 KOKKOS_LAMBDA(int icx, int icy, int icz) {
118 coarsenOpenAvgCell(oxc, oyc, ozc, oxf, oyf, ozf, cext, fext, gc, gf, ratio, icx, icy, icz);
119 });
120}
121
122// --- WO-R2 item 1: the OUTFLOW face coefficient on a variable-density operator ----------------
123//
124// `applyBoundaryOpenness` used to re-impose the literal 1.0 at a Dirichlet (outflow) domain face on
125// EVERY level, which under `varRho` overwrote `buildRhoCoeff`'s `open_f * rho0/rho_f` with 1 and
126// made the operator row disagree with the projection correction by the full density ratio (WO-R's
127// headline defect: the Nusselt film's low-side outlet, `max|w|` 1.455 against a film `u_max` of
128// 0.312). The face index convention makes the two sides ASYMMETRIC:
129// * the LOW domain face of an axis is the INNER index `g` -> `buildRhoCoeff` (and, on a
130// coarse level, `coarsenOpenAvg`) already wrote the right value there; the fix is simply not
131// to overwrite it.
132// * the HIGH domain face is the GHOST index `dims-g` -> no kernel writes it, and the
133// periodic/halo ghost fill wraps the opposite boundary's value into it. It must be saved
134// before the fill and restored after (level 0, from the caller's field), and coarsened from
135// the fine level's own restored plane (coarse levels).
136// These three helpers do the save / restore / coarsen of ONE domain-face plane. They are only
137// reached when the caller sets `setOutflowCoefficient(true)` (the varRho pressure build); with it
138// off the literal-1.0 path is untouched and byte-identical.
139inline void mgSaveFacePlane(CCField dst, CCConst src, C3 e, int g, int a, int s) {
141 int dims[3] = {e.x, e.y, e.z};
142 long st[3] = {1, (long)e.x, (long)e.x * e.y};
143 const int b = (a + 1) % 3, c = (a + 2) % 3;
144 const long sa = st[a], sb = st[b], sc = st[c];
145 const int bf = (s == 0) ? g : (dims[a] - g);
146 const int db = dims[b];
147 using MD = MDRange2<CCExec>;
148 Kokkos::parallel_for(
149 "peclet::flow::mg_save_face_plane", MD(space, {0, 0}, {dims[b], dims[c]}),
150 KOKKOS_LAMBDA(int p0, int p1) {
151 dst((long)p0 + (long)p1 * db) = src((long)p0 * sb + (long)p1 * sc + (long)bf * sa);
152 });
153}
154inline void mgRestoreFacePlane(CCField dst, CCConst src, C3 e, int g, int a, int s) {
156 int dims[3] = {e.x, e.y, e.z};
157 long st[3] = {1, (long)e.x, (long)e.x * e.y};
158 const int b = (a + 1) % 3, c = (a + 2) % 3;
159 const long sa = st[a], sb = st[b], sc = st[c];
160 const int bf = (s == 0) ? g : (dims[a] - g);
161 const int db = dims[b];
162 using MD = MDRange2<CCExec>;
163 Kokkos::parallel_for(
164 "peclet::flow::mg_restore_face_plane", MD(space, {0, 0}, {dims[b], dims[c]}),
165 KOKKOS_LAMBDA(int p0, int p1) {
166 dst((long)p0 * sb + (long)p1 * sc + (long)bf * sa) = src((long)p0 + (long)p1 * db);
167 });
168}
169// Coarse domain-face plane = the area average of the ratio_b*ratio_c fine sub-faces it spans —
170// the same rule `coarsenOpenAvg` applies to every interior face, restricted to the one plane the
171// coarsening loop cannot reach (the high side lives on a ghost index). The coarse plane's own
172// TRANSVERSE ghost ring is filled by clamping the fine transverse index into range: it is read
173// only by the redundant ring rows of a distributed CA (g=2) level's smoother, and it is exact
174// whenever the coefficient is constant along the outlet (every constant-density case, and every
175// ratio-1 case, so the byte-identity and MPI gates are unaffected).
176inline void mgCoarsenFacePlane(CCField oc, CCConst of, C3 cext, C3 fext, int gc, int gf, C3 cinner,
177 C3 ratio, int a, int s) {
179 int cd[3] = {cext.x, cext.y, cext.z}, fd[3] = {fext.x, fext.y, fext.z};
180 int ci[3] = {cinner.x, cinner.y, cinner.z}, rt[3] = {ratio.x, ratio.y, ratio.z};
181 long cst[3] = {1, (long)cext.x, (long)cext.x * cext.y};
182 long fst[3] = {1, (long)fext.x, (long)fext.x * fext.y};
183 const int b = (a + 1) % 3, c = (a + 2) % 3;
184 const long csa = cst[a], csb = cst[b], csc = cst[c];
185 const long fsa = fst[a], fsb = fst[b], fsc = fst[c];
186 const int cbf = (s == 0) ? gc : (cd[a] - gc);
187 const int fbf = (s == 0) ? gf : (fd[a] - gf);
188 const int rb = rt[b], rc = rt[c];
189 const int fdb = fd[b], fdc = fd[c];
190 const double inv = 1.0 / (double)(rb * rc);
191 using MD = MDRange2<CCExec>;
192 Kokkos::parallel_for(
193 "peclet::flow::mg_coarsen_face_plane", MD(space, {0, 0}, {cd[b], cd[c]}),
194 KOKKOS_LAMBDA(int p0, int p1) {
195 const int ib = p0 - gc, ic = p1 - gc; // coarse INNER indices (negative in the ghost ring)
196 double sum = 0.0;
197 for (int q0 = 0; q0 < rb; ++q0)
198 for (int q1 = 0; q1 < rc; ++q1) {
199 int fb = rb * ib + gf + q0, fc = rc * ic + gf + q1;
200 fb = fb < 0 ? 0 : (fb >= fdb ? fdb - 1 : fb);
201 fc = fc < 0 ? 0 : (fc >= fdc ? fdc - 1 : fc);
202 sum += of((long)fb * fsb + (long)fc * fsc + (long)fbf * fsa);
203 }
204 oc((long)p0 * csb + (long)p1 * csc + (long)cbf * csa) = sum * inv;
205 });
206 (void)ci;
207}
208
209// residual r = b - A x for the float operator (mg_residual_var_k port). A0 cell body (§5.2).
210template <class RV, class XV, class BV, class OpV>
211KOKKOS_INLINE_FUNCTION void cutcellResidualCell(const RV& r, const XV& x, const BV& b,
212 const OpV& AC, const OpV& AW, const OpV& AE,
213 const OpV& AS, const OpV& AN, const OpV& AB,
214 const OpV& AT, long i, long xp, long xm, long yp,
215 long ym, long zp, long zm) {
216 const double Ax = (double)AC(i) * x(i) + (double)AE(i) * x(xp) + (double)AW(i) * x(xm) +
217 (double)AN(i) * x(yp) + (double)AS(i) * x(ym) + (double)AT(i) * x(zp) +
218 (double)AB(i) * x(zm);
219 r(i) = b(i) - Ax;
220}
221inline void residualCutcell(CCField r, CCConst x, CCConst b, FPC AC, FPC AW, FPC AE, FPC AS, FPC AN,
222 FPC AB, FPC AT, C3 e, int g) {
223 ccFor3(
224 "peclet::flow::cc_residual", C3{g, g, g}, C3{e.x - g, e.y - g, e.z - g},
225 KOKKOS_LAMBDA(int lx, int ly, int lz) {
226 const long sx = 1, sy = e.x, sz = (long)e.x * e.y;
227 const long i = (long)lx + (long)ly * sy + (long)lz * sz;
228 cutcellResidualCell(r, x, b, AC, AW, AE, AS, AN, AB, AT, i, i + sx, i - sx, i + sy, i - sy,
229 i + sz, i - sz);
230 });
231}
232
233// Residual over a box [rlo,rhi) minus a skip box [slo,shi) — the halo-overlapped form of
234// residualCutcell (interior first while the exchange is in flight, then the boundary shell).
235inline void residualCutcellBox(CCField r, CCConst x, CCConst b, FPC AC, FPC AW, FPC AE, FPC AS,
236 FPC AN, FPC AB, FPC AT, C3 e, C3 rlo, C3 rhi, C3 slo, C3 shi) {
237 if (rhi.x <= rlo.x || rhi.y <= rlo.y || rhi.z <= rlo.z)
238 return;
239 ccFor3(
240 "peclet::flow::cc_residual_box", rlo, rhi, KOKKOS_LAMBDA(int lx, int ly, int lz) {
241 if (lx >= slo.x && lx < shi.x && ly >= slo.y && ly < shi.y && lz >= slo.z && lz < shi.z)
242 return; // inside the skip box (already done by the interior pass)
243 const long sx = 1, sy = e.x, sz = (long)e.x * e.y;
244 const long i = (long)lx + (long)ly * sy + (long)lz * sz;
245 cutcellResidualCell(r, x, b, AC, AW, AE, AS, AN, AB, AT, i, i + sx, i - sx, i + sy, i - sy,
246 i + sz, i - sz);
247 });
248}
249
250// Face-form siblings of residualCutcell / residualCutcellBox (A2, see mac_pressure.hpp).
251inline void residualCutcellFace(CCField r, CCConst x, CCConst b, FPC AC, FPC AFX, FPC AFY, FPC AFZ,
252 C3 e, int g) {
253 ccFor3(
254 "peclet::flow::cc_residual", C3{g, g, g}, C3{e.x - g, e.y - g, e.z - g},
255 KOKKOS_LAMBDA(int lx, int ly, int lz) {
256 const long sx = 1, sy = e.x, sz = (long)e.x * e.y;
257 const long i = (long)lx + (long)ly * sy + (long)lz * sz;
258 cutcellResidualFaceCell(r, x, b, AC, AFX, AFY, AFZ, i, sx, sy, sz, i + sx, i - sx, i + sy,
259 i - sy, i + sz, i - sz);
260 });
261}
262inline void residualCutcellBoxFace(CCField r, CCConst x, CCConst b, FPC AC, FPC AFX, FPC AFY,
263 FPC AFZ, C3 e, C3 rlo, C3 rhi, C3 slo, C3 shi) {
264 if (rhi.x <= rlo.x || rhi.y <= rlo.y || rhi.z <= rlo.z)
265 return;
266 ccFor3(
267 "peclet::flow::cc_residual_box", rlo, rhi, KOKKOS_LAMBDA(int lx, int ly, int lz) {
268 if (lx >= slo.x && lx < shi.x && ly >= slo.y && ly < shi.y && lz >= slo.z && lz < shi.z)
269 return; // inside the skip box (already done by the interior pass)
270 const long sx = 1, sy = e.x, sz = (long)e.x * e.y;
271 const long i = (long)lx + (long)ly * sy + (long)lz * sz;
272 cutcellResidualFaceCell(r, x, b, AC, AFX, AFY, AFZ, i, sx, sy, sz, i + sx, i - sx, i + sy,
273 i - sy, i + sz, i - sz);
274 });
275}
276
277// --- A3: fused periodic wrap (doc/vof_step_performance_design.md §4.3, §5.4) -------------------
278// On a single-rank level whose ghosts a periodic fill would only COPY from the inner cells (no
279// outflow face, no overlay on the solve), the smoother, the residual and the Krylov matvec read a
280// boundary cell's periodic neighbour directly through the wrapped index instead of from a ghost
281// filled by a launch before each pass: per axis a, for inner coordinate k in [g, g+n_a),
282// minus neighbour = (k == g) ? i + (n_a-1) s_a : i - s_a
283// plus neighbour = (k == g+n_a-1) ? i - (n_a-1) s_a : i + s_a.
284// That is the very cell the fill copies into the ghost, so a read-only pass (residual, matvec)
285// reads the identical value. A red-black colour pass does too exactly when every inner dimension is
286// EVEN: the wrapped neighbour then has the other colour, which this pass does not update, so it
287// still holds the value the fill copied before the pass (the caller checks the parity). Only the
288// field being smoothed or applied is wrapped; the coefficients are static over a solve and are read
289// at their ghost indices as before (AFX(i+sx) at the high ghost holds the periodic face, and a wall
290// face's coefficient is 0 there, multiplying the wrapped value exactly as it multiplied the ghost).
291struct CcNbrs {
292 long xp, xm, yp, ym, zp, zm;
293};
294KOKKOS_INLINE_FUNCTION CcNbrs ccWrapNbrs(int lx, int ly, int lz, C3 n, int g, long i, long sy,
295 long sz) {
296 CcNbrs w;
297 w.xm = (lx == g) ? i + (long)(n.x - 1) : i - 1;
298 w.xp = (lx == g + n.x - 1) ? i - (long)(n.x - 1) : i + 1;
299 w.ym = (ly == g) ? i + (long)(n.y - 1) * sy : i - sy;
300 w.yp = (ly == g + n.y - 1) ? i - (long)(n.y - 1) * sy : i + sy;
301 w.zm = (lz == g) ? i + (long)(n.z - 1) * sz : i - sz;
302 w.zp = (lz == g + n.z - 1) ? i - (long)(n.z - 1) * sz : i + sz;
303 return w;
304}
305// cutcellSmoothColorFace with wrapped neighbour reads (n = the level's inner dims, all even).
306inline void cutcellSmoothColorFaceWrap(CCField phi, CCConst b, FPC AC, FPC AFX, FPC AFY, FPC AFZ,
307 C3 e, C3 n, C3 og, int g, int color) {
309 if constexpr (std::is_same_v<typename CCExec::memory_space, Kokkos::HostSpace>) {
310 const int nyi = e.y - 2 * g, nzi = e.z - 2 * g;
311 const long cells = (long)nyi * nzi * (e.x - 2 * g);
312 auto pencil = KOKKOS_LAMBDA(long t) {
313 const int ly = g + (int)(t % nyi), lz = g + (int)(t / nyi);
314 const long sx = 1, sy = e.x, sz = (long)e.x * e.y;
315 const int P = (color + og.x + og.y + ly + og.z + lz) & 1;
316 PECLET_FLOW_OMP_SIMD // same-colour cells are independent (ccFor3's contract)
317 for (int lx = g + ((P ^ (g & 1)) & 1); lx < e.x - g; lx += 2) {
318 const long i = (long)lx + (long)ly * sy + (long)lz * sz;
319 const CcNbrs w = ccWrapNbrs(lx, ly, lz, n, g, i, sy, sz);
320 cutcellSmoothFaceCell(phi, b, AC, AFX, AFY, AFZ, i, sx, sy, sz, w.xp, w.xm, w.yp, w.ym,
321 w.zp, w.zm);
322 }
323 };
324 if (hostRunSerial(cells)) { // coarse MG level: the fork/join costs more than the sweep
325 for (long t = 0; t < (long)nyi * nzi; ++t)
326 pencil(t);
327 return;
328 }
329 Kokkos::parallel_for("peclet::flow::cc_smooth",
330 Kokkos::RangePolicy<CCExec>(space, 0, (long)nyi * nzi), pencil);
331 return;
332 }
333 using MD = MDRange3<CCExec>;
334 Kokkos::parallel_for(
335 "peclet::flow::cc_smooth", MD(space, {g, g, g}, {e.x - g, e.y - g, e.z - g}),
336 KOKKOS_LAMBDA(int lx, int ly, int lz) {
337 if (((og.x + lx + og.y + ly + og.z + lz) & 1) != color)
338 return;
339 const long sx = 1, sy = e.x, sz = (long)e.x * e.y;
340 const long i = (long)lx + (long)ly * sy + (long)lz * sz;
341 const CcNbrs w = ccWrapNbrs(lx, ly, lz, n, g, i, sy, sz);
342 cutcellSmoothFaceCell(phi, b, AC, AFX, AFY, AFZ, i, sx, sy, sz, w.xp, w.xm, w.yp, w.ym,
343 w.zp, w.zm);
344 });
345}
346// residualCutcellFace with wrapped reads of x.
348 FPC AFZ, C3 e, C3 n, int g) {
349 ccFor3(
350 "peclet::flow::cc_residual", C3{g, g, g}, C3{e.x - g, e.y - g, e.z - g},
351 KOKKOS_LAMBDA(int lx, int ly, int lz) {
352 const long sx = 1, sy = e.x, sz = (long)e.x * e.y;
353 const long i = (long)lx + (long)ly * sy + (long)lz * sz;
354 const CcNbrs w = ccWrapNbrs(lx, ly, lz, n, g, i, sy, sz);
355 cutcellResidualFaceCell(r, x, b, AC, AFX, AFY, AFZ, i, sx, sy, sz, w.xp, w.xm, w.yp, w.ym,
356 w.zp, w.zm);
357 });
358}
359// applyCutcellOpFace / applyCutcellOpExact with wrapped reads of x (the level-0 Krylov matvec).
360inline void applyCutcellOpFaceWrap(CCField y, CCConst x, FPC AC, FPC AFX, FPC AFY, FPC AFZ, C3 e,
361 C3 n, int g) {
362 ccFor3(
363 "peclet::flow::cc_apply", C3{g, g, g}, C3{e.x - g, e.y - g, e.z - g},
364 KOKKOS_LAMBDA(int lx, int ly, int lz) {
365 const long sx = 1, sy = e.x, sz = (long)e.x * e.y;
366 const long i = (long)lx + (long)ly * sy + (long)lz * sz;
367 const CcNbrs w = ccWrapNbrs(lx, ly, lz, n, g, i, sy, sz);
368 y(i) = cutcellApplyFaceCell(x, AC, AFX, AFY, AFZ, i, sx, sy, sz, w.xp, w.xm, w.yp, w.ym,
369 w.zp, w.zm);
370 });
371}
373 C3 n, int g, double gfx, double gfy, double gfz) {
374 ccFor3(
375 "peclet::flow::cc_apply_exact", C3{g, g, g}, C3{e.x - g, e.y - g, e.z - g},
376 KOKKOS_LAMBDA(int lx, int ly, int lz) {
377 const long sx = 1, sy = e.x, sz = (long)e.x * e.y;
378 const long i = (long)lx + (long)ly * sy + (long)lz * sz;
379 const CcNbrs w = ccWrapNbrs(lx, ly, lz, n, g, i, sy, sz);
380 y(i) = cutcellApplyExactCell(x, ox, oy, oz, i, sx, sy, sz, w.xp, w.xm, w.yp, w.ym, w.zp,
381 w.zm, gfx, gfy, gfz);
382 });
383}
384
385// average restriction (coarse = mean of ratio^3 fine children; mg_restrict_k) + trilinear
386// prolongation (added to fine; mg_prolong_k). Both over inner cells. gc/gf: coarse/fine block
387// ghost widths (CA-eligible coarse levels carry g=2, so they can differ across one transfer).
388// A0 cell bodies (§5.2).
389template <class CV, class FV>
391 int gc, int gf, C3 ratio, int icx, int icy, int icz) {
392 const long fsy = fext.x, fsz = (long)fext.x * fext.y;
393 double s = 0;
394 for (int dz = 0; dz < ratio.z; ++dz)
395 for (int dy = 0; dy < ratio.y; ++dy)
396 for (int dx = 0; dx < ratio.x; ++dx) {
397 const int fx = ratio.x * icx + dx + gf, fy = ratio.y * icy + dy + gf,
398 fz = ratio.z * icz + dz + gf;
399 s += fine((long)fx + (long)fy * fsy + (long)fz * fsz);
400 }
401 const long ci =
402 (long)(icx + gc) + (long)(icy + gc) * cext.x + (long)(icz + gc) * (long)cext.x * cext.y;
403 coarse(ci) = s / (double)(ratio.x * ratio.y * ratio.z);
404}
405template <class FV, class CV>
407 int gf, int gc, C3 ratio, int ifx, int ify, int ifz) {
408 // The coarse sample coordinate is 0.5*ifine - 0.25 + gc on a coarsened axis (ratio 2) and
409 // ifine + gc on a kept one (ratio 1); its floor is the lower coarse index and its fraction the
410 // weight. §14 H-2: both in integer arithmetic. The coordinate is exact in double, so its floor
411 // and fraction are exactly
412 // ratio 2: x0 = (i >> 1) + gc - 1 + (i & 1), w = 0.25 (i odd) or 0.75 (i even);
413 // ratio 1: x0 = i + gc, w = 0,
414 // and the interpolation below (textually unchanged) sees the same operands -- bit-identical,
415 // without three floors and six FP <-> int conversions per fine cell.
416 const int x0 = (ratio.x == 2) ? (ifx >> 1) + gc - 1 + (ifx & 1) : ifx + gc;
417 const int y0 = (ratio.y == 2) ? (ify >> 1) + gc - 1 + (ify & 1) : ify + gc;
418 const int z0 = (ratio.z == 2) ? (ifz >> 1) + gc - 1 + (ifz & 1) : ifz + gc;
419 const double wx = (ratio.x == 2) ? ((ifx & 1) ? 0.25 : 0.75) : 0.0;
420 const double wy = (ratio.y == 2) ? ((ify & 1) ? 0.25 : 0.75) : 0.0;
421 const double wz = (ratio.z == 2) ? ((ifz & 1) ? 0.25 : 0.75) : 0.0;
422 const long sy = cext.x, sz = (long)cext.x * cext.y;
423 auto C = [&](int xx, int yy, int zz) { return coarse((long)xx + (long)yy * sy + (long)zz * sz); };
424 const double c00 = C(x0, y0, z0) * (1 - wx) + C(x0 + 1, y0, z0) * wx;
425 const double c10 = C(x0, y0 + 1, z0) * (1 - wx) + C(x0 + 1, y0 + 1, z0) * wx;
426 const double c01 = C(x0, y0, z0 + 1) * (1 - wx) + C(x0 + 1, y0, z0 + 1) * wx;
427 const double c11 = C(x0, y0 + 1, z0 + 1) * (1 - wx) + C(x0 + 1, y0 + 1, z0 + 1) * wx;
428 const double c0 = c00 * (1 - wy) + c10 * wy, c1 = c01 * (1 - wy) + c11 * wy;
429 const long fi =
430 (long)(ifx + gf) + (long)(ify + gf) * fext.x + (long)(ifz + gf) * (long)fext.x * fext.y;
431 fine(fi) += c0 * (1 - wz) + c1 * wz;
432}
433// The subtract half of CutcellMG::removeMean (A0 cell body).
434template <class FV, class AV>
435KOKKOS_INLINE_FUNCTION void meanSubtractCell(const FV& f, const AV& ac, long i, double mean) {
436 if (ac(i) > 1e-30f)
437 f(i) -= mean;
438}
439inline void restrictAvg(CCField coarse, CCConst fine, C3 cext, C3 fext, int gc, int gf, C3 cinner,
440 C3 ratio) {
441 ccFor3(
442 "peclet::flow::restrict", C3{0, 0, 0}, C3{cinner.x, cinner.y, cinner.z},
443 KOKKOS_LAMBDA(int icx, int icy, int icz) {
444 restrictAvgCell(coarse, fine, cext, fext, gc, gf, ratio, icx, icy, icz);
445 });
446}
447// A5 (doc/vof_step_performance_design.md §5.5): restrictAvg that also zeroes the coarse ITERATE on
448// the same inner cells in the same kernel, replacing the full-array zero fill of `cs.x` the V-cycle
449// launched after the restriction. The coarse ghosts are no longer zeroed; every consumer fills,
450// wrap-reads or overwrites them first (the smoother and the residual fill or wrap before reading,
451// GraphAMG writes the whole array). VelocityMG keeps the plain restrictAvg.
453 int gc, int gf, C3 cinner, C3 ratio) {
454 ccFor3(
455 "peclet::flow::restrict", C3{0, 0, 0}, C3{cinner.x, cinner.y, cinner.z},
456 KOKKOS_LAMBDA(int icx, int icy, int icz) {
457 restrictAvgCell(coarse, fine, cext, fext, gc, gf, ratio, icx, icy, icz);
458 coarseX((long)(icx + gc) + (long)(icy + gc) * cext.x +
459 (long)(icz + gc) * (long)cext.x * cext.y) = 0.0;
460 });
461}
462inline void prolongAdd(CCField fine, CCConst coarse, C3 fext, C3 cext, int gf, int gc, C3 finner,
463 C3 ratio) {
464 ccFor3(
465 "peclet::flow::prolong", C3{0, 0, 0}, C3{finner.x, finner.y, finner.z},
466 KOKKOS_LAMBDA(int ifx, int ify, int ifz) {
467 prolongAddCell(fine, coarse, fext, cext, gf, gc, ratio, ifx, ify, ifz);
468 });
469}
470
471// --- The direct bottom solve (doc/vof_step_performance_design.md §5.7, §5.14, §13, §14 H-1) -----
472// On every backend (host backends since §14 H-1) an ELIGIBLE agglomerated bottom (single rank,
473// singular operator, <= 8192 inner cells, 1-64 fluid components (BottomLabelKernel), an axis whose
474// planes hold <= 192 cells; CutcellMG::directBottomIneligible) is solved in ONE launch instead of
475// the host GraphAMG round trip: a single team runs flexible CG (Polak-Ribiere) in FP64 on the
476// bottom level's own operator, preconditioned by the block-tridiagonal FP32 direct factor of
477// mg_bottom_direct.hpp (§13; factored by its own launch once per operator change), to a relative
478// infinity-norm residual of tau = 1e-5 (E3), capped at 100 iterations. The constants are fixed, not
479// setters. Ghost width 1 (single rank). B1's V-cycle preconditioner over a geometric sub-hierarchy
480// (§5.7) was retired by §13 D-4; its code is in the history (the WO-6 / WO-11 commits).
481inline constexpr long kBottomMaxCells = 8192;
482// tau: 1e-5 since E3 (§7 WO-6, Q2): 1e-8, 1e-6 and 1e-5 give the SAME outer iteration count on
483// every one of the 50 bubble-column steps (653 total).
484inline constexpr double kBottomTau = 1e-5;
485inline constexpr int kBottomMaxComponents = 64; // B1b (§5.14): more -> GraphAMG
486inline constexpr int kBottomCap = 100;
487// §14 H-1: the team size of the solve launch on a HOST backend, min(this, team_size_max) -- one
488// Zen CCX (the factor launch: kBottomHostFactorTeam). The factor and M are bitwise independent of T
489// by construction (§13.2), and on a host backend the FCG's reductions are single-lane
490// (BottomKernel::teamSum / teamMax), so the host bottom's bits do not depend on T or
491// OMP_NUM_THREADS; T affects speed only.
492inline constexpr int kBottomHostTeam = 8;
493// §14 H-1, A(b): the FACTOR launch's host team size. The host schedule (mg_bottom_direct.hpp) has
494// ~2.5 barriers per tile and one-thread diagonal tiles, so it scales little past two threads:
495// bench_bottom_factor (16x12x8 walls-y, P = 12, b = 128) on a loaded 24-core Zen 3, ms per factor,
496// T = 1 / 2 / 3 / 4 / 8: 1.44 / 1.09 / 0.93-1.79 / 0.86-1.70 / 1.43-1.88 -- two is the fastest
497// size that is also stable under load. Bitwise free, like kBottomHostTeam.
498inline constexpr int kBottomHostFactorTeam = 2;
499
501 C3 ext{0, 0, 0}, inner{0, 0, 0};
503};
504
505// B1b (§5.14): the fluid components of the bottom level. Deterministic min-label propagation over
506// the faces whose coefficient is > 0 (the face form stores the band sign: AF < 0) between two fluid
507// cells (AC > 1e-30, removeMean's predicate), iterated to its fixed point inside one team kernel:
508// every fluid cell ends with the smallest flat index of its component, whatever the update order
509// (Jacobi sweeps between two buffers here). Then, in flat (label) order, one thread numbers the
510// components 0, 1, ... and counts their cells. Solid cells get -1. Out: comp (ids), cnt (cells per
511// component, the first kBottomMaxComponents), nc(0) = the number of components. Geometry time only.
513 using Member = Kokkos::TeamPolicy<CCExec>::member_type;
514 C3 ext{0, 0, 0}, inner{0, 0, 0};
516 Kokkos::View<int*, CCMem> comp, tmp;
517 Kokkos::View<long*, CCMem> cnt;
518 Kokkos::View<int*, CCMem> nc;
519 // §13.4.2: with a slow axis (slow >= 0), each component's last plane kc(c) (the largest
520 // coordinate along `slow` among its cells) and aug(c) = 1/m_c, m_c = its cells in that plane.
521 int slow = -1;
522 Kokkos::View<int*, CCMem> kc;
523 Kokkos::View<double*, CCMem> aug;
524 KOKKOS_INLINE_FUNCTION long cell(int q, int& lx, int& ly, int& lz) const {
525 const int t = q / inner.x;
526 lx = 1 + (q - t * inner.x);
527 lz = t / inner.y;
528 ly = 1 + (t - lz * inner.y);
529 lz += 1;
530 return (long)lx + (long)ly * ext.x + (long)lz * (long)ext.x * ext.y;
531 }
532 // one Jacobi sweep src -> dst; returns the number of labels that moved
533 KOKKOS_INLINE_FUNCTION int sweep(const Member& t, const Kokkos::View<int*, CCMem>& src,
534 const Kokkos::View<int*, CCMem>& dst) const {
535 const int ninner = inner.x * inner.y * inner.z;
536 const long sy = ext.x, sz = (long)ext.x * ext.y;
537 int moved = 0;
538 Kokkos::parallel_reduce(
539 Kokkos::TeamThreadRange(t, ninner),
540 [&](int q, int& acc) {
541 int lx, ly, lz;
542 const long i = cell(q, lx, ly, lz);
543 int m = src(i);
544 if (m >= 0) {
545 const CcNbrs w = ccWrapNbrs(lx, ly, lz, inner, 1, i, sy, sz);
546 const long nb[6] = {w.xp, w.xm, w.yp, w.ym, w.zp, w.zm};
547 const double cf[6] = {(double)AFX(i + 1), (double)AFX(i), (double)AFY(i + sy),
548 (double)AFY(i), (double)AFZ(i + sz), (double)AFZ(i)};
549 for (int f = 0; f < 6; ++f) {
550 const int lj = src(nb[f]);
551 if (cf[f] < 0.0 && lj >= 0 && lj < m)
552 m = lj;
553 }
554 if (m < src(i))
555 acc += 1;
556 }
557 dst(i) = m;
558 },
559 moved); // a sum (the identity of Max<int> is INT_MIN, not 0)
560 return moved;
561 }
563 const int ninner = inner.x * inner.y * inner.z;
564 Kokkos::parallel_for(Kokkos::TeamThreadRange(t, ninner), [&](int q) {
565 int lx, ly, lz;
566 const long i = cell(q, lx, ly, lz);
567 comp(i) = (AC(i) > 1e-30f) ? (int)i : -1;
568 });
569 t.team_barrier();
570 for (;;) { // two sweeps per round, so the labels end in `comp`
571 sweep(t, comp, tmp);
572 t.team_barrier();
573 const int moved = sweep(t, tmp, comp);
574 t.team_barrier();
575 if (!moved)
576 break;
577 }
578 Kokkos::single(Kokkos::PerTeam(t), [&]() {
579 int n = 0;
580 for (int q = 0; q < ninner; ++q) { // number the roots in label (flat-index) order
581 int lx, ly, lz;
582 const long i = cell(q, lx, ly, lz);
583 if (comp(i) == (int)i)
584 tmp(i) = n++;
585 }
586 for (int c = 0; c < kBottomMaxComponents; ++c)
587 cnt(c) = 0;
588 for (int q = 0; q < ninner; ++q) {
589 int lx, ly, lz;
590 const long i = cell(q, lx, ly, lz);
591 if (comp(i) >= 0) {
592 const int id = tmp(comp(i));
593 comp(i) = id;
594 if (id < kBottomMaxComponents)
595 cnt(id) += 1;
596 }
597 }
598 nc(0) = n;
599 if (slow >= 0) {
600 long m[kBottomMaxComponents];
601 for (int c = 0; c < kBottomMaxComponents; ++c) {
602 kc(c) = -1;
603 m[c] = 0;
604 }
605 for (int pass = 0; pass < 2; ++pass)
606 for (int q = 0; q < ninner; ++q) {
607 int lx, ly, lz;
608 const long i = cell(q, lx, ly, lz);
609 const int c = comp(i);
611 continue;
612 const int k = (slow == 0 ? lx : (slow == 1 ? ly : lz)) - 1;
613 if (pass == 0 && k > kc(c))
614 kc(c) = k;
615 else if (pass == 1 && k == kc(c))
616 m[c] += 1;
617 }
618 for (int c = 0; c < kBottomMaxComponents; ++c)
619 aug(c) = m[c] > 0 ? 1.0 / (double)m[c] : 0.0;
620 }
621 });
622 }
623};
624
625// The bottom solve kernel: one TeamPolicy(1, T) launch per bottom solve. FCG in FP64 on the stored
626// operator (A0 cell bodies); M = the direct factor `dir` (FacReal = float in production), whose
627// dir.stat(0) = 0 (a factor that failed even with the largest shift) takes the failure path.
628template <class FacReal = float>
630 using Member = Kokkos::TeamPolicy<CCExec>::member_type;
633 CCField x, b, r, p, z, zp, ap; // the bottom's solution and rhs (its x, rhs), FCG vectors
634 int precondOnly = 0; // test hook: z = M(b) and nothing else
635 int noMeanForTest = 0; // test hook: M without the component means' removal
636 // B1b (§5.14): the bottom's fluid components -- comp(i) in [0, nc) on fluid cells, -1 on cells
637 // with AC <= 1e-30 (which keep x = 0) -- their cell counts, and a scratch for their means.
638 int nc = 1;
639 Kokkos::View<const int*, CCMem> comp;
640 Kokkos::View<const long*, CCMem> ccnt;
641 Kokkos::View<double*, CCMem> cmean;
642 Kokkos::View<double*, CCMem> ks; // the Krylov slots; ks(flagSlot) = 1 on a non-finite scalar
643 int flagSlot = 0;
644 Kokkos::View<int*, CCMem> info; // info(0) = inner iterations of the last solve
645
646 // Every phase is one TeamThreadRange pass over the level followed by a barrier; the matvec reads
647 // periodic neighbours through wrapped indices (the A3 rule, §4.3), with 32-bit index arithmetic.
649 return l.inner.x * l.inner.y * l.inner.z;
650 }
652 return l.ext.x * l.ext.y * l.ext.z;
653 }
654 // inner cell q (x fastest) -> local coordinates (ghost width 1) and the flat index
655 KOKKOS_INLINE_FUNCTION static long cell(const BottomLevel& l, int q, int& lx, int& ly, int& lz) {
656 const int nx = l.inner.x, ny = l.inner.y;
657 const int t = q / nx;
658 lx = 1 + (q - t * nx);
659 lz = t / ny;
660 ly = 1 + (t - lz * ny);
661 lz += 1;
662 return (long)lx + (long)ly * l.ext.x + (long)lz * (long)l.ext.x * l.ext.y;
663 }
664 // §14 H-1: on a HOST backend every reduction of the FCG skeleton (the dots, the max-norms, the
665 // per-component sums of the mean projections) is computed by ONE lane, in ascending inner index
666 // (= ascending ext index) over the same cell set, and broadcast to the team: its bits are then
667 // independent of the team size and of OMP_NUM_THREADS (about 8 x 1536 FMA per FCG iteration).
668 // A device backend keeps the team reductions.
669 static constexpr bool kHostLane = std::is_same_v<CCMem, Kokkos::HostSpace>;
670 // sum over q in [0, n) of f(q, acc) (f adds its term to acc)
671 template <class F>
672 KOKKOS_INLINE_FUNCTION static double teamSum(const Member& t, int n, const F& f) {
673 double s = 0.0;
674 if constexpr (kHostLane) {
675 Kokkos::single(
676 Kokkos::PerTeam(t),
677 [&](double& v) {
678 double acc = 0.0;
679 for (int q = 0; q < n; ++q)
680 f(q, acc);
681 v = acc;
682 },
683 s);
684 } else {
685 Kokkos::parallel_reduce(Kokkos::TeamThreadRange(t, n), f, s);
686 }
687 return s;
688 }
689 // max over q in [0, n) of f(q, acc) (f raises acc to its term), from 0
690 template <class F>
691 KOKKOS_INLINE_FUNCTION static double teamMax(const Member& t, int n, const F& f) {
692 double s = 0.0;
693 if constexpr (kHostLane) {
694 Kokkos::single(
695 Kokkos::PerTeam(t),
696 [&](double& v) {
697 double acc = 0.0;
698 for (int q = 0; q < n; ++q)
699 f(q, acc);
700 v = acc;
701 },
702 s);
703 } else {
704 Kokkos::parallel_reduce(Kokkos::TeamThreadRange(t, n), f, Kokkos::Max<double>(s));
705 }
706 return s;
707 }
708 KOKKOS_INLINE_FUNCTION double dot(const Member& t, const BottomLevel& l, const CCField& a,
709 const CCField& c) const {
710 return teamSum(t, nInner(l), [&](int q, double& acc) {
711 int lx, ly, lz;
712 const long i = cell(l, q, lx, ly, lz);
713 if (l.AC(i) > 1e-30f)
714 acc += a(i) * c(i);
715 });
716 }
717 // The bottom's mean removal, per fluid component (§5.14): one team reduction per label, in label
718 // order, then one subtraction pass.
719 KOKKOS_INLINE_FUNCTION void removeMeanBottom(const Member& t, const CCField& f) const {
720 const BottomLevel& l = lv;
721 for (int c = 0; c < nc; ++c) {
722 const double s = teamSum(t, nInner(l), [&](int q, double& acc) {
723 int lx, ly, lz;
724 const long i = cell(l, q, lx, ly, lz);
725 if (comp(i) == c)
726 acc += f(i);
727 });
728 Kokkos::single(Kokkos::PerTeam(t), [&]() { cmean(c) = s / (double)ccnt(c); });
729 }
730 t.team_barrier();
731 Kokkos::parallel_for(Kokkos::TeamThreadRange(t, nInner(l)), [&](int q) {
732 int lx, ly, lz;
733 const long i = cell(l, q, lx, ly, lz);
734 const int c = comp(i);
735 if (c >= 0)
736 f(i) -= cmean(c);
737 });
738 t.team_barrier();
739 }
740 // z = M r (§13.4.4): the direct factor's M, then the component means' removal (step 5). With
741 // `keep`, z's previous values go there first.
742 KOKKOS_INLINE_FUNCTION void applyM(const Member& t, const CCField& R, const CCField& Z,
743 const CCField* keep) const {
744 dir.apply(t, R, Z, comp, keep);
745 if (!noMeanForTest)
746 removeMeanBottom(t, Z);
747 }
749 const BottomLevel& B0 = lv;
750 if (precondOnly) {
751 applyM(t, b, z, nullptr);
752 return;
753 }
754 const long sy = B0.ext.x, sz = (long)B0.ext.x * B0.ext.y;
755 Kokkos::parallel_for(Kokkos::TeamThreadRange(t, nExt(B0)), [&](int i) { x(i) = 0.0; });
756 // r = b on the fluid cells and 0 on the solid ones (GraphAMG's identity rows with a zero rhs)
757 Kokkos::parallel_for(Kokkos::TeamThreadRange(t, nInner(B0)), [&](int q) {
758 int lx, ly, lz;
759 const long i = cell(B0, q, lx, ly, lz);
760 r(i) = (comp(i) >= 0) ? b(i) : 0.0;
761 });
762 t.team_barrier();
763 removeMeanBottom(t, r); // b -= mean_fluid(b), per component
764 // max|r| over the fluid cells
765 auto rmax = [&](int q, double& acc) {
766 int lx, ly, lz;
767 const long i = cell(B0, q, lx, ly, lz);
768 if (B0.AC(i) > 1e-30f) {
769 const double v = Kokkos::fabs(r(i));
770 if (v > acc)
771 acc = v;
772 }
773 };
774 const double r0 = teamMax(t, nInner(B0), rmax);
775 int it = 0;
776 // a direct factor that failed even with the largest shift (non-finite input): the failure path
777 bool fail = dir.stat(0) == 0;
778 if (r0 != 0.0 && !fail) {
779 applyM(t, r, z, nullptr);
780 Kokkos::parallel_for(Kokkos::TeamThreadRange(t, nExt(B0)), [&](int i) { p(i) = z(i); });
781 t.team_barrier();
782 double rz = dot(t, B0, r, z);
783 while (it < kBottomCap) {
784 ++it;
785 // Ap = A_b p: the band apply reading p's periodic neighbours through the wrap
786 Kokkos::parallel_for(Kokkos::TeamThreadRange(t, nInner(B0)), [&](int q) {
787 int lx, ly, lz;
788 const long i = cell(B0, q, lx, ly, lz);
789 const CcNbrs w = ccWrapNbrs(lx, ly, lz, B0.inner, 1, i, sy, sz);
790 ap(i) = cutcellApplyFaceCell(p, B0.AC, B0.AFX, B0.AFY, B0.AFZ, i, 1L, sy, sz, w.xp, w.xm,
791 w.yp, w.ym, w.zp, w.zm);
792 });
793 t.team_barrier();
794 const double pAp = dot(t, B0, p, ap);
795 if (!(Kokkos::isfinite(pAp) && pAp > 1e-300)) {
796 fail = !Kokkos::isfinite(pAp); // a tiny pAp is a converged direction, not a failure
797 break;
798 }
799 const double a = rz / pAp;
800 // x += a p; r -= a Ap on the fluid cells (a cell with AC <= 1e-30 keeps x = 0), then
801 // r -= its component mean, then max|r|
802 Kokkos::parallel_for(Kokkos::TeamThreadRange(t, nInner(B0)), [&](int q) {
803 int lx, ly, lz;
804 const long i = cell(B0, q, lx, ly, lz);
805 if (comp(i) >= 0) {
806 x(i) += a * p(i);
807 r(i) -= a * ap(i);
808 }
809 });
810 t.team_barrier();
812 const double rn = teamMax(t, nInner(B0), rmax);
813 if (rn <= kBottomTau * r0)
814 break;
815 applyM(t, r, z, &zp); // zp = z (the previous one), z = M r
816 // <r, z - zp>: Polak-Ribiere (robust to M's rounding)
817 const double rzc = teamSum(t, nInner(B0), [&](int q, double& acc) {
818 int lx, ly, lz;
819 const long i = cell(B0, q, lx, ly, lz);
820 if (B0.AC(i) > 1e-30f)
821 acc += r(i) * (z(i) - zp(i));
822 });
823 const double beta = rzc / rz;
824 rz = dot(t, B0, r, z);
825 if (!(Kokkos::isfinite(rz) && Kokkos::isfinite(beta))) {
826 fail = true;
827 break;
828 }
829 Kokkos::parallel_for(Kokkos::TeamThreadRange(t, nInner(B0)), [&](int q) {
830 int lx, ly, lz;
831 const long i = cell(B0, q, lx, ly, lz);
832 p(i) = z(i) + beta * p(i);
833 });
834 t.team_barrier();
835 }
836 }
837 if (fail) { // x = 0 on the bottom and the device flag; the host turns it into solveFailed_
838 Kokkos::parallel_for(Kokkos::TeamThreadRange(t, nExt(B0)), [&](int i) { x(i) = 0.0; });
839 Kokkos::single(Kokkos::PerTeam(t), [&]() { ks(flagSlot) = 1.0; });
840 t.team_barrier();
841 } else {
842 removeMeanBottom(t, x); // x -= mean_fluid(x), per component
843 }
844 Kokkos::single(Kokkos::PerTeam(t), [&]() { info(0) = it; });
845 }
846};
847
848// Env-gated MG diagnostics (host printf only, off unless PECLET_FLOW_MG_DEBUG is set):
849// 1 = level table (per-level global/local dims + coarsening ratio) at build time
850// 2 = + PCG/V-cycle residual history for the first PECLET_FLOW_MG_DEBUG_SOLVES (default 3) solves
851// Used to diagnose decomposition-dependent iteration counts; no effect on the solve itself.
852inline int mgDebugLevel() {
853 static const int lv = [] {
854 const char* e = std::getenv("PECLET_FLOW_MG_DEBUG");
855 return e ? std::atoi(e) : 0;
856 }();
857 return lv;
858}
859inline int mgDebugSolves() {
860 static const int n = [] {
861 const char* e = std::getenv("PECLET_FLOW_MG_DEBUG_SOLVES");
862 return e ? std::atoi(e) : 3;
863 }();
864 return n;
865}
866
867// Communication-avoiding smoothing (`set_comm_avoiding`): exchange a 2-deep ghost layer once per
868// red-black PAIR instead of 1-deep before every colour, redundantly re-smoothing the 1-deep ghost
869// ring of the first colour so the second colour reads exactly the values a per-colour exchange
870// would have delivered — bit-identical, at half the halo events. Consumed by CutcellMG's coarse
871// levels and by the momentum RB-GS in flow_ibm.hpp. The two subsystems are switched independently
872// so a measured regression can be ATTRIBUTED to one of them without a rebuild; both are on by
873// default.
874enum : int { kCaMomentum = 1, kCaMg = 2, kCaBoth = kCaMomentum | kCaMg };
875
877 public:
878#ifdef PECLET_FLOW_MPI
879 struct Telescope; // defined after Level
880#endif
881 struct Level {
882 C3 ext, inner, ratio{2, 2, 2}, cfac{1, 1, 1};
883 C3 og{0, 0, 0}; // block inner origin in GLOBAL cells; {0,0,0} single-rank
884 // This level's GLOBAL inner dims (== inner single-rank). With og it decides which ranks own a
885 // global domain face — see touchesGlobalFace(lv, f).
886 C3 gdim{0, 0, 0};
887 std::size_t n = 0;
888 // Ghost width of this level's block (1 default; 2 on distributed coarse levels eligible for
889 // communication-avoiding smoothing — see initMpi). Single-rank always 1 (byte-identical).
890 int g = 1;
891 bool caOk = false; // width-2 topology built and every rank's block extent >= 4
893 // A2 face form (mac_pressure.hpp): the diagonal + one coefficient per face, full extent.
895#ifdef PECLET_FLOW_MPI
896 std::shared_ptr<GridHaloTopology<3>> halo; // per-level topology (decomposed)
897 std::shared_ptr<GridHalo<double>> dev; // per-level ghost exchange
898 // This level's communicator: comm_ above the first telescope point, the roots-only
899 // sub-communicator below it. Every collective a LEVEL performs (removeMean's Allreduce, the
900 // bottom's gather) must use this, not comm_ — see Telescope.
901 MPI_Comm comm = MPI_COMM_NULL;
902 // Set on level L when the transition L -> L+1 telescopes: L+1 lives on FEWER ranks (the ORB
903 // tree truncated one or more levels, BlockDecomposer::agglomerated), and the V-cycle moves
904 // this level's residual down / correction up through a gather / scatter within disjoint rank
905 // groups. Null on an ordinary in-place transition.
906 std::shared_ptr<Telescope> tele;
907#endif
908 };
909#ifdef PECLET_FLOW_MPI
910 // Coarse-level telescoping (docs/MG_TELESCOPING_PLAN.md). When a per-rank block turns odd the
911 // in-place coarsening (coarsened(): coarse-local i <-> fine-local 2i on the SAME rank) is
912 // blocked, and without this the hierarchy simply stops -- at 384^3 on 1536 ranks it dies at a
913 // block of 3x6x4 with the coarsest global extent still 48, and the pressure iteration count
914 // grows 16.6 -> 38.7 across the ladder. Merging ORB siblings restores parity (the merged block's
915 // origin is its parent's split value), so: gather level L at ITS OWN resolution onto the group
916 // roots, and let the roots continue the geometric hierarchy on a sub-communicator. Ranks that
917 // are not roots idle below L (they join the gather and the scatter, on the parent comm, and skip
918 // the recursion). restrictAvg/prolongAdd/coarsenOpenAvg are untouched: they read fine INNER
919 // cells only / write fine INNER cells only, so the stage needs no halo of its own.
920 //
921 // The target, the communicators and the movement are core's (suite decision "Coarse-level
922 // redistribution lives in core; the hierarchies stay in the methods",
923 // amr/docs/amr_mg_core_boundary.md §7): the depth search is `chooseStageTarget` /
924 // `shallowestLiftableMerge` with flow's per-axis lift rule as the predicate, the group / roots
925 // communicators are `makeStageComm`'s, and the gather / scatter of a level field is a
926 // `RedistributeTopology` built once here and run every V-cycle. What stays in flow: this struct
927 // (flow's per-level record of a stage), the stage buffers, the scatter's ADD (core's backward
928 // overwrites) and the WO-R2 outflow ghost-plane gather (teleGatherPlane), which moves a plane
929 // beyond the inner block that the inner-cell topology does not describe.
930 struct Telescope {
931 // group = this rank's group (the ranks merged into one block, owner first), sub = the roots
932 // only (MPI_COMM_NULL on members). Owned and freed by the StageComm.
933 peclet::core::decomp::StageComm comm;
934 bool root() const { return comm.active; } // this rank owns the merged block
935 bool repartition() const { return comm.kind == peclet::core::decomp::StageKind::Repartition; }
936 int nMembers = 1;
937 int g = 1; // ghost width of level L (the stage buffers use it)
938 C3 mInner{}, mOg{}, mExt{}; // merged block (root): inner dims, global origin, extent
939 // per-member fine block geometry in group-rank order (root first), root only
940 std::vector<C3> memO, memS;
941 // the movement of a level-L field: this rank's inner cells <-> the merged block's (root)
942 peclet::core::decomp::RedistributeTopology<3, double> move;
943 // Repartition only: the level's decomposition and the target's (replicated), which the
944 // outflow ghost-plane movement (teleGatherPlaneRepartition) intersects.
945 peclet::core::decomp::BlockDecomposer<3> srcDec, dstDec;
946 std::vector<double> back; // teleScatterAdd's landing buffer, level L's padded layout
947 CCField res, x, ox, oy, oz; // stage buffers at resolution L on the merged block (root)
948 };
949#endif
950 static constexpr int G = 1; // level-0 / single-rank ghost width (the flow_ibm g=1 bridge)
951 // The red-black parity origin for a level's smoother: the parity convention is the single-rank
952 // g=1 one (parity of og+local index INCLUDING a 1-cell ghost offset), so a level with g=2 must
953 // shift its origin by g-1 per axis or its colours come out swapped against the g=1 reference
954 // (3 axes -> parity flips). og itself stays the true global inner origin (buildAmg needs it).
955 static C3 parityOg(const Level& lv) {
956 return C3{lv.og.x - lv.g + 1, lv.og.y - lv.g + 1, lv.og.z - lv.g + 1};
957 }
958
959 // ---- the aspect-ratio coarsening rule (doc/anisotropic_metric.md §5) -------------------------
960 // Shared by BOTH geometric hierarchies (VelocityMG calls it too): given this level's spacings
961 // H[a] = hp[a] * cfac[a] and the per-axis coarsenability canA[a] that today's tests produce
962 // (can(dim) single-rank, plus evenOn(dec, a) under MPI), return the per-axis coarsening ratio.
963 //
964 // aniso == false : ratio_a = 2 iff canA[a] -- TODAY'S DECISION, VERBATIM
965 // aniso == true : ratio_a = 2 iff canA[a] and H[a] < theta * min_{b : canA[b]} H[b]
966 //
967 // i.e. always coarsen the finest coarsenable axis and DEFER one that is already at least theta
968 // times coarser (§5.2: the spread halves per level until it is below theta and then stays
969 // there, so no coarse operator is ever more than theta^2 anisotropic in coefficient). On an
970 // isotropic metric every H in the candidate set is equal, 1 < theta, and the result is today's
971 // table bit for bit -- which is why the isotropic path is gated on the flag and not on the
972 // arithmetic (§5.4: the rule WOULD change the table after a telescoping merge).
973 //
974 // A DEFERRED axis is not a BLOCKED one: initMpi's telescope trigger keeps reading canA, so
975 // deferral alone never merges ranks (trap 6).
976 static C3 mgChooseRatio(const double H[3], const bool canA[3], bool aniso, double theta) {
977 C3 r{1, 1, 1};
978 if (!aniso) {
979 if (canA[0])
980 r.x = 2;
981 if (canA[1])
982 r.y = 2;
983 if (canA[2])
984 r.z = 2;
985 return r;
986 }
987 double hmin = 0.0;
988 bool any = false;
989 for (int a = 0; a < 3; ++a)
990 if (canA[a] && (!any || H[a] < hmin)) {
991 hmin = H[a];
992 any = true;
993 }
994 if (!any)
995 return r;
996 const double lim = theta * hmin;
997 if (canA[0] && H[0] < lim)
998 r.x = 2;
999 if (canA[1] && H[1] < lim)
1000 r.y = 2;
1001 if (canA[2] && H[2] < lim)
1002 r.z = 2;
1003 return r;
1004 }
1005
1011 void setMetric(const double hp[3]) {
1012 for (int a = 0; a < 3; ++a)
1013 hp_[a] = hp[a];
1014 aniso_ = !(hp_[0] == 1.0 && hp_[1] == 1.0 && hp_[2] == 1.0);
1015 }
1017 std::vector<C3> levelRatios() const {
1018 std::vector<C3> r;
1019 r.reserve(lv_.size());
1020 for (const auto& v : lv_)
1021 r.push_back(v.ratio);
1022 return r;
1023 }
1024
1025 // build the periodic level hierarchy: per axis, halve inner while even and >=2 (uniform when
1026 // cubic), capped at nLevels (mirrors DistributedPoissonMG::init uniform path).
1027 void init(int nx, int ny, int nz, int nLevels) {
1028 lv_.clear();
1029 amg_.reset();
1030 eigWarm_ = false; // D3: the kept power-iteration vectors belong to the old hierarchy
1031 gnxF_ = nx;
1032 gnyF_ = ny;
1033 gnzF_ = nz;
1034 C3 inner{nx, ny, nz}, cf{1, 1, 1};
1035 for (int L = 0; L < nLevels; ++L) {
1036 Level v;
1037 v.inner = inner;
1038 v.gdim = inner; // single-rank: the block IS the global grid
1039 v.ext = C3{inner.x + 2 * G, inner.y + 2 * G, inner.z + 2 * G};
1040 v.cfac = cf;
1041 v.n = (std::size_t)v.ext.x * v.ext.y * v.ext.z;
1042 auto can = [&](int d) { return (d % 2 == 0) && (d / 2 >= 2); };
1043 C3 next = inner;
1044 C3 ratio{1, 1, 1};
1045 if (L + 1 < nLevels) {
1046 // §5.1: the candidate set is today's `can()` per axis; the aspect rule then defers an axis
1047 // that is already >= theta times coarser than the finest candidate. Inert when !aniso_.
1048 const bool canA[3] = {can(inner.x), can(inner.y), can(inner.z)};
1049 const double H[3] = {hp_[0] * (double)cf.x, hp_[1] * (double)cf.y, hp_[2] * (double)cf.z};
1050 ratio = mgChooseRatio(H, canA, aniso_, aspectTheta_);
1051 if (ratio.x == 2)
1052 next.x = inner.x / 2;
1053 if (ratio.y == 2)
1054 next.y = inner.y / 2;
1055 if (ratio.z == 2)
1056 next.z = inner.z / 2;
1057 }
1058 v.ratio = ratio;
1059 v.x = CCField("mg_x", v.n);
1060 v.rhs = CCField("mg_rhs", v.n);
1061 v.res = CCField("mg_res", v.n);
1062 v.ox = CCField("mg_ox", v.n);
1063 v.oy = CCField("mg_oy", v.n);
1064 v.oz = CCField("mg_oz", v.n);
1065 for (FPV* p : {&v.AC, &v.AFX, &v.AFY, &v.AFZ})
1066 *p = FPV("mg_A", v.n);
1067 lv_.push_back(v);
1068 if (next.x == inner.x && next.y == inner.y && next.z == inner.z)
1069 break; // nothing coarsens
1070 inner = next;
1071 cf = C3{cf.x * ratio.x, cf.y * ratio.y, cf.z * ratio.z};
1072 }
1073 buildBottomStore(); // the direct bottom's storage (single rank, every backend)
1074 if (mgDebugLevel()) {
1075 printf("[mg] init %dx%dx%d single-rank -> %d levels (requested %d)\n", nx, ny, nz,
1076 (int)lv_.size(), nLevels);
1077 for (int L = 0; L < (int)lv_.size(); ++L)
1078 printf("[mg] L%d dims %4dx%4dx%4d ratio(%d,%d,%d)\n", L, lv_[L].inner.x, lv_[L].inner.y,
1079 lv_[L].inner.z, lv_[L].ratio.x, lv_[L].ratio.y, lv_[L].ratio.z);
1080 fflush(stdout);
1081 }
1082 }
1083#ifdef PECLET_FLOW_MPI
1084 // Multi-rank hierarchy: coarsen the GLOBAL grid 2:1 per level; each level gets its own core halo
1085 // over a BlockDecomposer of that level's grid (the ORB decomposition coarsens cleanly so
1086 // restrict/prolong stay local). Sets the distributed flag -> fill() exchanges, the reductions
1087 // Allreduce, the smoother uses the block's global-origin parity. Single-rank (size 1) reproduces
1088 // init()'s field exactly.
1089 // dec0: OPTIONAL shared level-0 decomposition (load-balance / CFD-DEM co-decomposition). When
1090 // given, level 0 uses it so the MG's level-0 block matches the caller's (possibly weighted)
1091 // block, and each coarse level is the previous one coarsened in place while every block stays
1092 // even. nullptr => the solver's own aligned ORB (`decomposition()`), byte-identical to before.
1093 // A WEIGHTED dec0 (rebalanceByWeights) generally has ODD splits: in-place coarsening blocks at
1094 // the shallowest odd split and a telescope stage fires there — at level 0 when the ORB root
1095 // split is odd. The sibling-merge search alone then lands at the shallowest even ORB depth,
1096 // which is ONE RANK (d = 0) whenever the root split is odd: the whole level gathered to one rank
1097 // every V-cycle. Measured (SCALING_ISSUES #2, 96^3 heap, np = 8): projection 2.25-2.6x slower,
1098 // iterations unchanged. The two escapes this comment used to recommend do NOT escape: the
1099 // GraphAMG bottom leaves that level-0 telescope untouched (0.085 -> 0.218 s), and nLevels == 1
1100 // either runs the redundant GraphAMG on the whole grid (the `auto` bottom, ~55x slower) or pure
1101 // RB-GS (the smoother bottom, slower than the collapse it replaces). What bounds it is
1102 // setRepartition(true), which the Solver sets for a weighted dec0: a stage never hands a rank
1103 // more cells than the largest level-0 block, and a merge that would is replaced by a Repartition
1104 // onto a proportional ORB of the level's grid on np_L ranks (core's chooseStageTarget with
1105 // maxBlockCells > 0, amr/docs/amr_mg_core_boundary.md §11.1-§11.3).
1106
1107 // flow's LIFT rule — "every block can coarsen in place one more time": every block is even in
1108 // origin and size on every axis whose GLOBAL extent `gs` can still halve (can()). An axis that
1109 // cannot halve is not a constraint. This is the predicate core's stage policy
1110 // (peclet::core::decomp::chooseStageTarget) is parameterised on, shared by initMpi and predict.
1111 static bool teleLiftable(const peclet::core::decomp::BlockDecomposer<3>& d, C3 gs) {
1112 auto can = [](int e) { return (e % 2 == 0) && (e / 2 >= 2); };
1113 const int ga[3] = {gs.x, gs.y, gs.z};
1114 for (int ax = 0; ax < 3; ++ax) {
1115 if (!can(ga[ax]))
1116 continue;
1117 for (std::size_t b = 0; b < d.sizes().size(); ++b)
1118 if ((d.origins()[b][ax] % 2) || (d.sizes()[b][ax] % 2))
1119 return false;
1120 }
1121 return true;
1122 }
1123 // Per-axis split alignment that makes an ORB safely coarsenable by this MG: align[k] =
1124 // 2^(number of times axis k can coarsen, until it turns odd) — the NATURAL MAXIMUM, independent
1125 // of the actual nLevels (over-aligning is harmless: coarsened() still divides cleanly at every
1126 // real level). Depends only on the global grid, so the solver's dec_, the mpi_block() sizing, and
1127 // this MG all compute the SAME value without threading nLevels. The solver builds its shared
1128 // decomposition with this alignment so initMpi derives nested coarse levels via coarsened().
1129 static peclet::core::IVec<3> coarsenAlignment(int gnx, int gny, int gnz) {
1130 auto can = [](int d) { return (d % 2 == 0) && (d / 2 >= 2); };
1131 C3 gs{gnx, gny, gnz};
1132 peclet::core::IVec<3> a{1, 1, 1};
1133 for (bool any = true; any;) {
1134 any = false;
1135 if (can(gs.x)) {
1136 a[0] *= 2;
1137 gs.x /= 2;
1138 any = true;
1139 }
1140 if (can(gs.y)) {
1141 a[1] *= 2;
1142 gs.y /= 2;
1143 any = true;
1144 }
1145 if (can(gs.z)) {
1146 a[2] *= 2;
1147 gs.z /= 2;
1148 any = true;
1149 }
1150 }
1151 // Cap at 2^(default nLevels - 1): all the 5-level hierarchy needs. The UNCAPPED natural-max
1152 // over-constrains the ORB on power-of-two-rich grids (e.g. 192^3 -> align 64): the split snap
1153 // then rounds a balanced 96|96 to 128|64 (cascading 2:1 load imbalance), and once sub-boxes
1154 // drop under 2*align the snap is skipped -> unaligned splits -> the even-coarsening gate
1155 // collapses the MG depth (measured: 192^3 np=24 pure-MPI, 27 pressure iters/step vs 9, 3.4x
1156 // step time). With the cap the same case decomposes perfectly evenly and keeps 5 nested
1157 // levels; axes whose natural alignment is smaller are unchanged, deeper hierarchies degrade
1158 // through the existing evenBlocks gate exactly as before.
1159 for (int k = 0; k < 3; ++k)
1160 if (a[k] > 16)
1161 a[k] = 16;
1162 return a;
1163 }
1164
1165 // ---- coarse-first ("decompose coarse, refine upward") decomposition ---------------------------
1166 // `levels` selects how the LEVEL-0 DECOMPOSITION is built: 0 (the default) = the legacy
1167 // aligned-ORB route above; L >= 2 = build the ORB on the grid coarsened L-1 times and refine the
1168 // partition upward, which guarantees L nested levels and balances on the coarse grid instead of
1169 // snapping fine splits afterwards. It is a PARAMETER, not process state: `decomposition()` is a
1170 // pure function of (numBlocks, grid, levels, maxImbalance), so every rank — and every call site
1171 // that must agree on the partition (`flow.mpi_block`, `IbmSolver::initMpi`) — computes the same
1172 // answer without communicating, and two solvers in one process can differ.
1173 // Per-axis coarsening factor a depth-`levels` hierarchy will actually apply: 2^(levels-1),
1174 // bounded by that axis's factors of two (an odd axis never coarsens, so its factor stays 1).
1175 static peclet::core::IVec<3> refineFactor(int gnx, int gny, int gnz, int levels) {
1176 auto can = [](int d) { return (d % 2 == 0) && (d / 2 >= 2); };
1177 C3 gs{gnx, gny, gnz};
1178 peclet::core::IVec<3> r{1, 1, 1};
1179 for (int L = 1; L < levels; ++L) {
1180 if (can(gs.x)) {
1181 r[0] *= 2;
1182 gs.x /= 2;
1183 }
1184 if (can(gs.y)) {
1185 r[1] *= 2;
1186 gs.y /= 2;
1187 }
1188 if (can(gs.z)) {
1189 r[2] *= 2;
1190 gs.z /= 2;
1191 }
1192 }
1193 return r;
1194 }
1195
1196 // THE shared level-0 decomposition. Every call site must go through this so the solver's block,
1197 // mpi_block()'s sizing and the MG's level 0 cannot drift apart.
1198 static peclet::core::decomp::BlockDecomposer<3> decomposition(std::size_t numBlocks, int gnx,
1199 int gny, int gnz, int levels = 0,
1200 double maxImbalance = 1.05) {
1201 if (levels < 2)
1202 return peclet::core::decomp::BlockDecomposer<3>(
1203 numBlocks, peclet::core::IVec<3>{gnx, gny, gnz}, coarsenAlignment(gnx, gny, gnz));
1204 // Depth and load balance pull against each other: each extra level doubles the quantum on every
1205 // axis that still coarsens, and a partition built on the coarse grid can only place a split on
1206 // a coarse-cell boundary. So rather than guess a granularity, BUILD each candidate and measure
1207 // its imbalance: take the deepest one that stays within budget, else keep the legacy aligned
1208 // ORB. The whole search is a pure function of (numBlocks, grid, levels) — every rank computes
1209 // the same answer without communicating.
1210 if (!(maxImbalance > 1.0))
1211 maxImbalance = 1.05;
1212 auto imbalanceOf = [](const peclet::core::decomp::BlockDecomposer<3>& d) {
1213 std::size_t hi = 0, lo = std::numeric_limits<std::size_t>::max();
1214 for (const auto& s : d.sizes()) {
1215 const std::size_t n = static_cast<std::size_t>(s[0]) * static_cast<std::size_t>(s[1]) *
1216 static_cast<std::size_t>(s[2]);
1217 hi = n > hi ? n : hi;
1218 lo = n < lo ? n : lo;
1219 }
1220 return lo ? static_cast<double>(hi) / static_cast<double>(lo)
1221 : std::numeric_limits<double>::infinity();
1222 };
1223 for (int L = levels; L >= 2; --L) {
1224 const peclet::core::IVec<3> r = refineFactor(gnx, gny, gnz, L);
1225 if (r[0] == 1 && r[1] == 1 && r[2] == 1)
1226 break; // nothing coarsens on any axis — the aligned ORB is all there is
1227 const std::size_t cells = static_cast<std::size_t>(gnx / r[0]) *
1228 static_cast<std::size_t>(gny / r[1]) *
1229 static_cast<std::size_t>(gnz / r[2]);
1230 if (cells < numBlocks)
1231 continue; // a coarse grid thinner than the rank count would hand someone an empty block
1232 // Decompose the COARSE grid — telling the ORB each coarse cell's true extent, so it picks the
1233 // same split axes the fine grid would — then refine the partition upward. Blocks come out as
1234 // exact multiples of r, so every level nests for the full depth.
1235 peclet::core::decomp::BlockDecomposer<3> coarse;
1236 coarse.init(numBlocks, peclet::core::IVec<3>{gnx / r[0], gny / r[1], gnz / r[2]},
1237 peclet::core::IVec<3>{1, 1, 1}, r);
1238 peclet::core::decomp::BlockDecomposer<3> fine = coarse.refined(r);
1239 if (imbalanceOf(fine) <= maxImbalance) {
1240 if (mgDebugLevel())
1241 printf("[mg] decomposition: coarse-first depth %d (refine %dx%dx%d, imbalance %.3f)\n", L,
1242 r[0], r[1], r[2], imbalanceOf(fine));
1243 return fine;
1244 }
1245 }
1246 return peclet::core::decomp::BlockDecomposer<3>(numBlocks, peclet::core::IVec<3>{gnx, gny, gnz},
1247 coarsenAlignment(gnx, gny, gnz));
1248 }
1249
1250 void initMpi(int gnx, int gny, int gnz, int nLevels, MPI_Comm comm,
1251 const peclet::core::decomp::BlockDecomposer<3>* dec0 = nullptr) {
1252 lv_.clear();
1253 amg_.reset();
1254 eigWarm_ = false; // D3: the kept power-iteration vectors belong to the old hierarchy
1255 distributed_ = true;
1256 buildBottomStore(); // single-rank only: none (the distributed path keeps GraphAMG)
1257 comm_ = comm;
1258 gnxF_ = gnx;
1259 gnyF_ = gny;
1260 gnzF_ = gnz;
1261 int rank = 0, size = 1;
1262 MPI_Comm_rank(comm, &rank);
1263 MPI_Comm_size(comm, &size);
1264 std::array<bool, 3> per{true, true, true};
1265 C3 gs{gnx, gny, gnz}, cf{1, 1, 1};
1266 auto can = [&](int d) { return (d % 2 == 0) && (d / 2 >= 2); };
1267 // Coarse levels are NESTED: level 0 is the shared solver decomposition (dec0), and each coarse
1268 // level is the previous level's decomposition coarsened IN PLACE (same tree/leaf order, split
1269 // positions halved). This keeps restrict/prolong's coarse-local i <-> fine-local ratio*i
1270 // mapping valid on every rank. (An independent ORB per level does NOT nest — coarse blocks can
1271 // split a different axis than the fine level, sending restrict/prolong out of bounds.)
1272 peclet::core::decomp::BlockDecomposer<3> curDec;
1273 if (dec0) {
1274 curDec = *dec0; // solver's shared decomposition (built aligned; see flow_ibm initMpi)
1275 } else {
1276 curDec = decomposition(static_cast<std::size_t>(size), gs.x, gs.y, gs.z);
1277 }
1278 // Below a telescope point the hierarchy continues on a sub-communicator of the group roots:
1279 // these track the communicator/rank a level is built on (rank == block index at every level,
1280 // by construction of the sub-communicator's keys).
1281 MPI_Comm curComm = comm;
1282 int curRank = rank;
1283 teleActive_ = true;
1284 // setRepartition: a stage never hands a rank more cells than the largest LEVEL-0 block
1285 // (design §11.1 — the finest level's largest block, replicated); 0 = sibling merges only.
1286 const peclet::core::Index maxBlockCells =
1287 repartition_ ? peclet::core::decomp::largestBlockCells(curDec) : 0;
1288 auto evenOn = [](const peclet::core::decomp::BlockDecomposer<3>& d, int ax) {
1289 for (std::size_t b = 0; b < d.sizes().size(); ++b)
1290 if ((d.origins()[b][ax] % 2) || (d.sizes()[b][ax] % 2))
1291 return false;
1292 return true;
1293 };
1294 // Smallest block extent (any rank, any axis) — the same on every rank (the decomposition is
1295 // replicated), so the per-level ghost-width decision below is rank-uniform by construction.
1296 auto minBlockExtent = [](const peclet::core::decomp::BlockDecomposer<3>& d) {
1297 long m = std::numeric_limits<long>::max();
1298 for (const auto& s : d.sizes())
1299 for (int k = 0; k < 3; ++k)
1300 m = std::min(m, (long)s[k]);
1301 return m;
1302 };
1303 for (int L = 0; L < nLevels; ++L) {
1304 Level v;
1305 v.halo = std::make_shared<GridHaloTopology<3>>();
1306 const peclet::core::decomp::BlockDecomposer<3>& dec = curDec;
1307 // Communication-avoiding smoothing needs a 2-deep ghost layer; give it to the COARSE levels
1308 // (where every halo message is pure latency) whose blocks can carry it (extent >= 4 on every
1309 // rank; below that fall back to the per-colour exchange). Level 0 keeps g=1: its exchanges
1310 // are already overlapped with the interior sweep and the solver's g=1 bridge (openness/rhs/
1311 // phi staging, ghost-projection g=2 staging) assumes it. Only for the periodic/IBM operator
1312 // — with domain BCs (setBoundaryConditions BEFORE initMpi) every level keeps the g=1 layout,
1313 // so that path is byte-identical to the pre-CA code.
1314 v.g = (L > 0 && !hasBC_ && (caMode_ & kCaMg) && minBlockExtent(dec) >= 4) ? 2 : 1;
1315 v.caOk = (v.g == 2);
1316 v.halo->buildTopology(dec, curRank, v.g, per, curComm);
1317 v.comm = curComm;
1318 v.dev = std::make_shared<GridHalo<double>>();
1319 v.dev->init(*v.halo);
1320 v.dev->setLabel("mg L" + std::to_string(L) + " g" + std::to_string(v.g) + " ranks" +
1321 std::to_string(curDec.numBlocks()));
1322 const auto& idx = v.halo->indexer();
1323 const auto eg = idx.sizeInclGhost(), ino = idx.sizeInner(), oig = idx.originInclGhost();
1324 v.ext = {(int)eg[0], (int)eg[1], (int)eg[2]};
1325 v.inner = {(int)ino[0], (int)ino[1], (int)ino[2]};
1326 v.og = {(int)oig[0] + v.g, (int)oig[1] + v.g,
1327 (int)oig[2] + v.g}; // inner origin == single-rank og=0 at origin 0
1328 v.gdim = gs; // this level's GLOBAL dims (og + inner == gdim -> owns +face)
1329 v.cfac = cf;
1330 v.n = idx.numCellsInclGhost();
1331 C3 next = gs, ratio{1, 1, 1};
1332 bool idleBelow = false;
1333 if (L + 1 < nLevels) {
1334 // Coarsen an axis only if the GLOBAL dim can (can()) AND every rank's block is even on that
1335 // axis, so coarsened() nests exactly. Alignment (coarsenAlignment) makes this hold for the
1336 // full natural depth; on an unaligned/awkward decomposition it used to simply stop
1337 // coarsening that axis (fewer geometric levels) — with telescoping ON, a blocked axis
1338 // instead merges ORB siblings onto fewer ranks and carries on (see Telescope).
1339 namespace cdec = peclet::core::decomp;
1340 const bool canAny = can(gs.x) || can(gs.y) || can(gs.z);
1341 // flow's lift rule, the predicate core's stage policy is parameterised on.
1342 auto liftable = [&](const cdec::BlockDecomposer<3>& d) { return teleLiftable(d, gs); };
1343 const bool blocked = !liftable(curDec);
1344 const bool tooSmall = teleMinExtent_ > 0 &&
1345 cdec::minBlockExtent(curDec) < (peclet::core::Index)teleMinExtent_;
1346 const bool doTele = canAny && curDec.numBlocks() > 1 &&
1347 ((telescope_ && (blocked || tooSmall)) || teleForce_ == L);
1348 if (doTele) {
1349 // Fewest merges (largest tree depth) at which EVERY still-coarsenable axis has even
1350 // blocks — "any axis" would be wrong: it can unblock z and leave x frozen at the extent
1351 // that matters. depth 0 (one block: origin 0, size gs, even by can()) always qualifies;
1352 // with the economic trigger a merged candidate must also stay fat enough after the
1353 // halving that follows. That search is core's `shallowestLiftableMerge`, and behind
1354 // flow's trigger it is `chooseStageTarget` with maxBlockCells = 0 (byte-identical to the
1355 // search flow carried inline until S4). The test-only forced telescope runs the search
1356 // where in-place coarsening is legal, i.e. the search on its own.
1357 std::optional<cdec::StageTarget<3>> t;
1358 if (teleForce_ == L) {
1359 if (auto m = cdec::shallowestLiftableMerge(curDec, liftable, teleMinExtent_)) {
1360 t.emplace();
1361 t->kind = cdec::StageKind::SiblingMerge;
1362 t->dec = std::move(m->dec);
1363 t->ownerOf = std::move(m->ownerOf);
1364 t->groupOf = std::move(m->groupOf);
1365 }
1366 } else {
1367 t = cdec::chooseStageTarget(curDec, peclet::core::IVec<3>{gs.x, gs.y, gs.z}, liftable,
1368 teleMinExtent_, maxBlockCells);
1369 if (t->kind != cdec::StageKind::SiblingMerge && t->kind != cdec::StageKind::Repartition)
1370 throw std::logic_error(
1371 "CutcellMG::initMpi: the telescope search found no merge (depth 0 always lifts "
1372 "under flow's rule)");
1373 }
1374 if (t) {
1375 auto T = std::make_shared<Telescope>();
1376 T->comm = cdec::makeStageComm(curComm, *t);
1377 T->g = v.g;
1378 // The target block this rank's data moves into: its merge group's (SiblingMerge — the
1379 // root owns it, the members only feed it), or the one it owns (Repartition; none on a
1380 // rank that idles below).
1381 const int tb = T->repartition() ? T->comm.myTargetBlock : T->comm.myGroup;
1382 if (tb >= 0) {
1383 const auto mb = t->dec.block((std::size_t)tb);
1384 T->mOg = C3{(int)mb.origin[0], (int)mb.origin[1], (int)mb.origin[2]};
1385 T->mInner = C3{(int)mb.size[0], (int)mb.size[1], (int)mb.size[2]};
1386 T->mExt = C3{T->mInner.x + 2 * T->g, T->mInner.y + 2 * T->g, T->mInner.z + 2 * T->g};
1387 }
1388 if (T->repartition()) {
1389 T->srcDec = curDec;
1390 T->dstDec = t->dec;
1391 }
1392 // members in group-comm rank order == ascending old block index (makeStageComm)
1393 for (const int b : T->comm.members) {
1394 const auto fb = curDec.block((std::size_t)b);
1395 T->memO.push_back(C3{(int)fb.origin[0], (int)fb.origin[1], (int)fb.origin[2]});
1396 T->memS.push_back(C3{(int)fb.size[0], (int)fb.size[1], (int)fb.size[2]});
1397 }
1398 T->nMembers = (int)T->comm.members.size();
1399 // The movement: a global cell of this rank's level-L block lives at its padded slot
1400 // (og, g, ext of the level); on the root, a cell of the merged block at the stage
1401 // buffer's (mOg, g, mExt). Built once; teleGather / teleScatterAdd run it.
1402 {
1403 const C3 lo = v.og, le = v.ext, mo = T->mOg, me = T->mExt;
1404 const int lg = v.g, mg = T->g;
1405 using peclet::core::Index;
1406 auto srcIdx = [&](const peclet::core::IVec<3>& c) {
1407 return (Index)(c[0] - lo.x + lg) + (Index)(c[1] - lo.y + lg) * le.x +
1408 (Index)(c[2] - lo.z + lg) * (Index)le.x * le.y;
1409 };
1410 auto dstIdx = [&](const peclet::core::IVec<3>& c) {
1411 return (Index)(c[0] - mo.x + mg) + (Index)(c[1] - mo.y + mg) * me.x +
1412 (Index)(c[2] - mo.z + mg) * (Index)me.x * me.y;
1413 };
1414 T->move.build(curDec, *t, T->comm, srcIdx, dstIdx, /*id=*/L);
1415 T->back.assign(v.n, 0.0);
1416 }
1417 if (T->root()) {
1418 const std::size_t mn = (std::size_t)T->mExt.x * T->mExt.y * T->mExt.z;
1419 T->res = CCField("tele_res", mn);
1420 T->x = CCField("tele_x", mn);
1421 T->ox = CCField("tele_ox", mn);
1422 T->oy = CCField("tele_oy", mn);
1423 T->oz = CCField("tele_oz", mn);
1424 }
1425 v.tele = T;
1426 if (T->root()) {
1427 curDec = t->dec;
1428 curComm = T->comm.sub;
1429 MPI_Comm_rank(curComm, &curRank);
1430 } else {
1431 idleBelow = true; // this rank holds levels 0..L only
1432 }
1433 }
1434 }
1435 auto evenBlocks = [&](int ax) { return evenOn(curDec, ax); };
1436 // §5.1 again, with the MPI candidate set: an axis can coarsen when the GLOBAL dim can and
1437 // every rank's (possibly just-merged) block is even on it. The aspect rule then defers
1438 // one that is already >= theta times coarser. Note `blocked` above is computed from
1439 // `can()` and `evenOn()` ALONE (trap 6): an axis deferred here is not blocked and must not
1440 // trigger a telescope merge. Every rank has the same doubles and the same decomposition,
1441 // so this is a pure function -- no communication.
1442 const bool canA[3] = {can(gs.x) && evenBlocks(0), can(gs.y) && evenBlocks(1),
1443 can(gs.z) && evenBlocks(2)};
1444 const double H[3] = {hp_[0] * (double)cf.x, hp_[1] * (double)cf.y, hp_[2] * (double)cf.z};
1445 ratio = mgChooseRatio(H, canA, aniso_, aspectTheta_);
1446 if (ratio.x == 2)
1447 next.x = gs.x / 2;
1448 if (ratio.y == 2)
1449 next.y = gs.y / 2;
1450 if (ratio.z == 2)
1451 next.z = gs.z / 2;
1452 }
1453 v.ratio = ratio;
1454 v.x = CCField("mg_x", v.n);
1455 v.rhs = CCField("mg_rhs", v.n);
1456 v.res = CCField("mg_res", v.n);
1457 v.ox = CCField("mg_ox", v.n);
1458 v.oy = CCField("mg_oy", v.n);
1459 v.oz = CCField("mg_oz", v.n);
1460 for (FPV* p : {&v.AC, &v.AFX, &v.AFY, &v.AFZ})
1461 *p = FPV("mg_A", v.n);
1462 lv_.push_back(v);
1463 if (idleBelow) {
1464 teleActive_ = false;
1465 break;
1466 }
1467 if (next.x == gs.x && next.y == gs.y && next.z == gs.z)
1468 break;
1469 gs = next;
1470 cf = C3{cf.x * ratio.x, cf.y * ratio.y, cf.z * ratio.z};
1471 // Next level's decomposition = this level's coarsened in place (nested; preserves rank
1472 // order).
1473 curDec = curDec.coarsened(peclet::core::IVec<3>{ratio.x, ratio.y, ratio.z});
1474 }
1475 if (mgDebugLevel() && rank == 0) {
1476 printf("[mg] initMpi %dx%dx%d np=%d -> %d levels (requested %d)%s\n", gnx, gny, gnz, size,
1477 (int)lv_.size(), nLevels, telescope_ ? " [telescope ON]" : "");
1478 C3 g{gnx, gny, gnz};
1479 int ranks = size;
1480 for (int L = 0; L < (int)lv_.size(); ++L) {
1481 printf(
1482 "[mg] L%d global %4dx%4dx%4d ranks %5d rank0 block %4dx%4dx%4d ratio(%d,%d,%d)%s\n",
1483 L, g.x, g.y, g.z, ranks, lv_[L].inner.x, lv_[L].inner.y, lv_[L].inner.z, lv_[L].ratio.x,
1484 lv_[L].ratio.y, lv_[L].ratio.z,
1485 lv_[L].tele ? (lv_[L].tele->repartition() ? " -> REPARTITION" : " -> TELESCOPE")
1486 : "");
1487 if (lv_[L].tele) {
1488 int sub = 1;
1489 MPI_Comm_size(lv_[L].tele->comm.sub, &sub);
1490 ranks = sub;
1491 }
1492 g = C3{g.x / lv_[L].ratio.x, g.y / lv_[L].ratio.y, g.z / lv_[L].ratio.z};
1493 }
1494 fflush(stdout);
1495 }
1496 }
1497#endif
1498 int nLevels() const { return (int)lv_.size(); }
1499 Level& level(int L) { return lv_[L]; }
1500
1501 // per-face domain BC types {-x,+x,-y,+y,-z,+z}: 0=periodic, 1/2/4=Neumann (wall/inflow/
1502 // free-slip), 3=Dirichlet (outflow). Default all-periodic -> applyBoundaryOpenness is a no-op
1503 // (periodic/IBM path byte-identical).
1504 void setBoundaryConditions(const int bc[6]) {
1505 hasBC_ = false;
1506 hasOutflow_ = false;
1507 for (int i = 0; i < 6; ++i) {
1508 bc_[i] = bc[i];
1509 if (bc[i])
1510 hasBC_ = true;
1511 if (bc[i] == 3)
1512 hasOutflow_ = true;
1513 }
1514 removeMean_ =
1515 !hasOutflow_; // singular all-Neumann -> remove mean; Dirichlet outflow -> non-singular
1516 }
1517 // hold the pressure/correction ghost at 0 on outflow faces (open face -> Dirichlet p=0). Call
1518 // after every (periodic) fill of a solution / search-direction field, on the level it lives
1519 // (g = that level's ghost width).
1520 void applyOutflowGhost(const Level& lv, CCField x, int g = G) {
1521 if (!hasOutflow_)
1522 return;
1523 B3 e{lv.ext.x, lv.ext.y, lv.ext.z};
1524 for (int a = 0; a < 3; ++a)
1525 for (int s = 0; s < 2; ++s)
1526 if (bc_[2 * a + s] == 3 && touchesGlobalFace(lv, 2 * a + s))
1527 bcZeroPressureGhost(x, e, g, a, s);
1528 }
1529 // Zero-gradient (Neumann) pressure/correction ghost on a wall/inflow face (BC type 1/2), the
1530 // counterpart of applyOutflowGhost's Dirichlet ghost. WHY IT EXISTS (WO-H, 2026-08-30): the
1531 // per-level ghost fill is PERIODIC on all three axes (fill()/GridHalo), so on a walled face a
1532 // level's `x` ghost carries the value from the OPPOSITE side of the domain. Every *operator*
1533 // consumer is immune — the wall face openness is 0, so the smoother/residual/matvec multiply that
1534 // ghost by AW/AE/... = 0 — but `prolongAdd` is NOT: trilinear interpolation reads the coarse
1535 // ghost with weight 1/4 whatever the openness, so the fine cells against a wall were receiving a
1536 // quarter of the coarse correction from the far wall. That teleport is a long-range coupling
1537 // present in P and absent from R, i.e. exactly the asymmetry that broke MG-PCG on domain-BC grids
1538 // (measured: dense-M skew ||M-M^T||F/||M||F 3.5-5.4 % wall-bounded vs 0.8 % periodic, and PCG
1539 // 200/200 vs 7). With the zero-gradient ghost the boundary fine cell simply takes the coarse
1540 // value (0.25*c0 + 0.75*c0 = c0), which is also what the constant-mode-preserving prolongation
1541 // must do. Call after every (periodic) fill of a level's solution field that a prolongation will
1542 // read.
1543 //
1544 // This is the pressure-side counterpart of `VelocityMG::fillProlongBcGhosts` /
1545 // `fillBcGhost` (mac_velocity_mg.hpp), the port of the retired CUDA `mg_fill_bc_ghost_k`: the
1546 // VELOCITY multigrid has always done both halves (Dirichlet 0 AND Neumann zero-gradient) before
1547 // its trilinear prolongation. The pressure MG only ever received the Dirichlet half
1548 // (applyOutflowGhost) — CLAUDE.md's "the trilinear prolongation fills the non-periodic boundary
1549 // ghosts (Neumann -> zero-gradient, Dirichlet -> 0)" described the intent, not the code.
1550 void applyNeumannGhost(const Level& lv, CCField x, int g = G) {
1551 if (!hasBC_ || !bcGhost_)
1552 return;
1553 B3 e{lv.ext.x, lv.ext.y, lv.ext.z};
1554 for (int a = 0; a < 3; ++a)
1555 for (int s = 0; s < 2; ++s) {
1556 const int t = bc_[2 * a + s];
1557 if ((t == 1 || t == 2 || t == 4) && touchesGlobalFace(lv, 2 * a + s))
1558 bcNeumannGhost(x, e, g, a, s);
1559 }
1560 }
1561 // Does this rank's block on level `lv` touch global domain face f? Always true single-rank
1562 // (og = 0 and gdim == inner), so every guarded BC application is byte-identical there.
1563 static bool touchesGlobalFace(const Level& lv, int f) {
1564 const int a = f / 2;
1565 const int o = (a == 0) ? lv.og.x : (a == 1) ? lv.og.y : lv.og.z;
1566 const int n = (a == 0) ? lv.inner.x : (a == 1) ? lv.inner.y : lv.inner.z;
1567 const int gd = (a == 0) ? lv.gdim.x : (a == 1) ? lv.gdim.y : lv.gdim.z;
1568 return (f % 2 == 0) ? (o == 0) : (o + n == gd);
1569 }
1570 // re-impose the non-periodic boundary openness a periodic fill leaves wrong: Neumann wall/inflow
1571 // -> 0 (closed), Dirichlet outflow -> left open. Call after every (periodic) openness fill, per
1572 // level.
1573 // `fine` = the next FINER level (null on level 0). Only the outflow-coefficient path reads it.
1574 void applyBoundaryOpenness(Level& lv, Level* fine = nullptr) {
1575 if (fine) {
1576 CCField fo[3] = {fine->ox, fine->oy, fine->oz};
1577 applyBoundaryOpennessFrom(lv, fo, fine->ext, fine->g, fine->ratio);
1578 } else {
1579 applyBoundaryOpennessFrom(lv, nullptr, C3{0, 0, 0}, 0, C3{1, 1, 1});
1580 }
1581 }
1582 // `fo`/`fext`/`fg`/`fratio` describe the FINE openness this level coarsens its outflow
1583 // coefficient plane from: the finer Level in place, or -- across a telescope point -- the merged
1584 // stage buffers (Telescope::ox/oy/oz on T.mExt with T.g), whose high-side outflow plane
1585 // teleGatherPlane carried over with the inner cells.
1587 if (!hasBC_)
1588 return;
1589 B3 e{lv.ext.x, lv.ext.y, lv.ext.z};
1590 CCField oa[3] = {lv.ox, lv.oy, lv.oz};
1591 for (int a = 0; a < 3; ++a)
1592 for (int s = 0; s < 2; ++s) {
1593 const int t = bc_[2 * a + s];
1594 if (!touchesGlobalFace(lv, 2 * a + s))
1595 continue; // interior rank boundary: the exchanged openness is the right value
1596 if (t == 1 || t == 2 || t == 4) {
1597 bcSetOpenness(oa[a], e, lv.g, a, s, 0.0); // wall/inflow/free-slip Neumann -> closed
1598 } else if (t == 3) {
1599 if (!outflowCoeff_) {
1600 bcSetOpenness(oa[a], e, lv.g, a, s, 1.0); // outflow -> open (periodic fill wraps)
1601 continue;
1602 }
1603 // WO-R2 item 1: the caller handed a COEFFICIENT field (open_f*rho0/rho_f), so the
1604 // literal 1.0 would overwrite it. Low side = an inner index the caller / the coarsening
1605 // already wrote correctly -> leave it. High side = a ghost index the fill wrapped ->
1606 // restore (level 0) or coarsen from the finer level's own restored plane.
1607 if (s == 0)
1608 continue;
1609 if (fo == nullptr) {
1610 mgRestoreFacePlane(oa[a], CCConst(bcPlane_[a]), lv.ext, lv.g, a, 1);
1611 } else {
1612 mgCoarsenFacePlane(oa[a], CCConst(fo[a]), lv.ext, fext, lv.g, fg, lv.inner, fratio, a,
1613 1);
1614 }
1615 }
1616 }
1617 }
1618 // Save the HIGH-side outflow face plane of a coefficient field before the (periodic/halo) ghost
1619 // fill destroys it. Called on level 0 only; the coarse levels re-derive theirs by averaging.
1621 CCField oa[3] = {lv.ox, lv.oy, lv.oz};
1622 int dims[3] = {lv.ext.x, lv.ext.y, lv.ext.z};
1623 for (int a = 0; a < 3; ++a) {
1624 if (bc_[2 * a + 1] != 3 || !touchesGlobalFace(lv, 2 * a + 1))
1625 continue;
1626 const int b = (a + 1) % 3, c = (a + 2) % 3;
1627 const std::size_t np = (std::size_t)dims[b] * dims[c];
1628 if (bcPlane_[a].extent(0) != np)
1629 bcPlane_[a] = CCField(
1630 Kokkos::view_alloc("peclet::flow::mg_bcplane", Kokkos::WithoutInitializing), np);
1631 mgSaveFacePlane(bcPlane_[a], CCConst(oa[a]), lv.ext, lv.g, a, 1);
1632 }
1633 }
1634
1635 // rediscretized cut-cell operator on every level from the fine face openness (idx2 = 1/dx^2
1636 // fine).
1637 void setOpenness(CCConst ox, CCConst oy, CCConst oz, double idx2, double idy2, double idz2) {
1638 Level& f = lv_[0];
1639 // Retained for the exact (matrix-free) level-0 apply, which re-derives the face coefficient
1640 // t_f = open_f * gf instead of reading the assembled band. All three call sites pass grid
1641 // units (1.0) today; store them rather than assume it.
1642 gfx_ = idx2;
1643 gfy_ = idy2;
1644 gfz_ = idz2;
1645 Kokkos::deep_copy(f.ox, ox);
1646 Kokkos::deep_copy(f.oy, oy);
1647 Kokkos::deep_copy(f.oz, oz);
1648 if (outflowCoeff_)
1649 saveOutflowPlanes(f); // WO-R2: the high-side outflow coefficient lives on a ghost index
1650 // that the fill below wraps over -- snapshot it first.
1652 f); // periodic fine-level openness ghosts (the operator reads the + neighbour face);
1653 // idempotent when the caller already filled them, required when it passed inner-only.
1655 f); // re-impose non-periodic wall/inflow faces the periodic fill clobbered
1656 buildCutcellOpFace(f.AC, f.AFX, f.AFY, f.AFZ, CCConst(f.ox), CCConst(f.oy), CCConst(f.oz),
1657 f.ext, G, idx2, idy2, idz2);
1658#ifdef PECLET_FLOW_MPI
1659 // A telescope point gathers this level's openness onto the group roots (all group ranks take
1660 // part); the next level coarsens from that stage. A rank idling below holds no next level, so
1661 // the loop simply ends for it after the gather.
1662 if (lv_[0].tele) {
1663 teleGather(lv_[0], lv_[0].ox, lv_[0].tele->ox);
1664 teleGather(lv_[0], lv_[0].oy, lv_[0].tele->oy);
1665 teleGather(lv_[0], lv_[0].oz, lv_[0].tele->oz);
1667 }
1668#endif
1669 for (int L = 1; L < (int)lv_.size(); ++L) {
1670 Level& c = lv_[L];
1671 Level& fin = lv_[L - 1];
1672#ifdef PECLET_FLOW_MPI
1673 if (fin.tele) {
1674 // Across a telescope point the fine openness is the merged STAGE (inner cells gathered by
1675 // teleGather; the WO-R2 high-side outflow coefficient plane, a ghost index, gathered by
1676 // teleGatherPlane), so the coarse boundary coefficient is coarsened from the stage exactly
1677 // as the in-place path coarsens it from the finer level.
1678 Telescope& T = *fin.tele;
1679 coarsenOpenAvg(c.ox, c.oy, c.oz, CCConst(T.ox), CCConst(T.oy), CCConst(T.oz), c.ext, T.mExt,
1680 c.g, T.g, c.inner, fin.ratio);
1681 fillOpenness(c);
1682 CCField so[3] = {T.ox, T.oy, T.oz};
1683 applyBoundaryOpennessFrom(c, so, T.mExt, T.g, fin.ratio);
1684 } else {
1685#endif
1686 coarsenOpenAvg(c.ox, c.oy, c.oz, CCConst(fin.ox), CCConst(fin.oy), CCConst(fin.oz), c.ext,
1687 fin.ext, c.g, fin.g, c.inner, fin.ratio);
1688 fillOpenness(c); // periodic ghost openness (operator build reads the + neighbour face)
1689 applyBoundaryOpenness(c, &fin); // re-impose non-periodic boundary faces per coarse level
1690#ifdef PECLET_FLOW_MPI
1691 }
1692#endif
1693 const double sx = 1.0 / (double)(c.cfac.x * c.cfac.x),
1694 sy = 1.0 / (double)(c.cfac.y * c.cfac.y),
1695 sz = 1.0 / (double)(c.cfac.z * c.cfac.z);
1696 // Width-2 (CA-eligible) levels also assemble the 1-deep ghost RING of the operator (build
1697 // box widened by 1): the ring rows are a deterministic function of the EXCHANGED openness,
1698 // so they come out bit-identical to the owning rank's inner rows — the redundant ring
1699 // re-smoothing of the CA sweep reads them. Inner rows are computed from the same operands
1700 // as the g-box build (identical). g=1 levels keep the inner-only build.
1701 buildCutcellOpFace(c.AC, c.AFX, c.AFY, c.AFZ, CCConst(c.ox), CCConst(c.oy), CCConst(c.oz),
1702 c.ext, c.g == 2 ? c.g - 1 : c.g, idx2 * sx, idy2 * sy, idz2 * sz);
1703#ifdef PECLET_FLOW_MPI
1704 if (c.tele) {
1705 teleGather(c, c.ox, c.tele->ox);
1706 teleGather(c, c.oy, c.tele->oy);
1707 teleGather(c, c.oz, c.tele->oz);
1709 }
1710#endif
1711 }
1712 // The operator (all levels, including the bottom) just changed: invalidate the agglomerated
1713 // GraphAMG bottom solve so the next solve rebuilds it from the CURRENT coefficients. The porous
1714 // and variable-rho paths rebuild the coefficients EVERY STEP — with a stale AMG bottom (frozen
1715 // at the first step's operator) the bottom "solve" answers a different matrix, the V-cycle
1716 // preconditioner drifts inconsistent/indefinite, and the outer PCG eventually breaks down and
1717 // NaNs the projection (observed as a sporadic, data-dependent blow-up in porous CFD-DEM). The
1718 // bottom level is tiny, so the per-step rebuild is negligible next to the V-cycles.
1719 amg_.reset();
1720 amgGlobalN_ = 0;
1721 // §13: the direct factor is rebuilt at the next direct bottom solve; the components
1722 // (condition 6) are labelled once per hierarchy, here at its first operator build.
1723 facStale_ = true;
1724 if (!bottomConnKnown_ && bottomStore_)
1726 }
1727
1728 // CG preconditioned by one symmetric V-cycle (solve_pcg port). rhs on level 0; solution left in
1729 // level-0 x. Returns the iteration count. Scratch supplied by the caller (level-0-sized fields).
1730 // Optional star overlay (mode-B fluid-only constraint): the SPD Kron-elimination couplings are
1731 // added to the fine-level matvec only; the hierarchy/preconditioner sees the filtered 7-point
1732 // surrogate it was built from (the symmetric sibling of solveBiCGStab's gp overlay pattern).
1733 // Single-rank v1: the star kernels wrap periodically over the inner grid, so no halo work.
1734 int solvePCG(CCField b, CCField x, CCField r, CCField p, CCField z, CCField Ap, int maxit,
1735 double rtol, int pre, int post, int bottom, const StarOverlay* star = nullptr,
1736 int nStar = 0, C3 nnStar = C3{0, 0, 0}) {
1737 if (!distributed_) // A6: the single-rank driver keeps its Krylov scalars on the device
1738 return solvePCGResident(b, x, r, p, z, Ap, maxit, rtol, pre, post, bottom, star, nStar,
1739 nnStar);
1740 solveFailed_ = false; // ISSUES sweep item 6: per-solve breakdown flag
1741 OverlayScope overlay(overlaySolve_, star != nullptr);
1742 pre_ = pre;
1743 post_ = post;
1744 bottom_ = bottom;
1745 Level& l0 = lv_[0];
1746 Kokkos::deep_copy(CCExec(), l0.x, x);
1747 auto matvec = [&](CCField y, CCField v) {
1748 matvecOverlap(l0, y, v);
1749 if (star)
1750 starApplyDelta(y, CCConst(v), *star, nStar, nnStar, l0.ext, G, l0.ext, G, exactResidual_);
1751 };
1752 auto precond = [&](CCField zz, CCField rr) { precondVcycle(zz, rr); }; // A5
1753 matvec(Ap, x); // r = b - A x
1754 Kokkos::deep_copy(CCExec(), r, b);
1755 axpy(r, -1.0, Ap);
1756 removeMean(l0, r); // compatibility: project rhs/residual onto the range
1757 const double r0 = maxabs(l0, r);
1758 // The stop reference: r0 (the default), or the caller's FULL right-hand-side norm when a
1759 // stop reference is set (setStopReference; only the balanced-force projection sets it).
1760 // With it unset rref IS r0, so `rtol * rref` is bit-for-bit the old test.
1761 const double rref = stopRef_ > 0.0 ? stopRef_ : r0;
1762 int it = 0;
1763 // Env-gated convergence trace (PECLET_FLOW_MG_DEBUG>=2): |b|inf, r0 and the per-iteration
1764 // residual, so a decomposition-dependent iteration count can be read as a rate (preconditioner
1765 // quality) or a floor (round-off) instead of guessed at.
1766 int dbgRank = 0;
1767#ifdef PECLET_FLOW_MPI
1768 if (distributed_)
1769 MPI_Comm_rank(comm_, &dbgRank);
1770#endif
1771 const bool trace = mgDebugLevel() >= 2 && dbgSolve_ < mgDebugSolves() && dbgRank == 0;
1772 if (trace)
1773 printf("[mg] solve %d: r0=%.6e rtol=%.1e (pre=%d post=%d bottom=%d)\n", dbgSolve_, r0, rtol,
1774 pre, post, bottom);
1775 ++dbgSolve_;
1776 // Breakdown guards: a non-finite recurrence scalar means the preconditioner or operator
1777 // produced NaN/Inf (should not happen — the guards fail safe rather than poisoning x with a
1778 // NaN alpha/beta and letting the projection silently corrupt every field downstream).
1779 if (r0 > 0.0 && std::isfinite(r0)) {
1780 precond(z, r);
1781 Kokkos::deep_copy(CCExec(), p, z);
1782 double rz = dot(l0, r, z);
1783 if (!std::isfinite(rz)) {
1784 // ISSUES sweep item 6: this is a FAILED solve, not a converged one. It used to print to
1785 // stdout, zero the correction and return 0 iterations, so a caller's rule-3b "no capped
1786 // pressure solve" check passed while the projection had been handed nothing. Report the
1787 // cap and raise the flag; set_pressure_strict(True) turns it into a throw.
1788 solveFailed_ = true;
1789 printf(
1790 "peclet::flow CutcellMG::solvePCG: preconditioner produced non-finite z; "
1791 "returning zero correction (reported as %d/%d iterations, i.e. a CAPPED solve)\n",
1792 maxit, maxit);
1793 Kokkos::deep_copy(CCExec(), x, 0.0);
1794 Kokkos::deep_copy(CCExec(), l0.x, x);
1795 if (strictPressure_)
1796 throw std::runtime_error(
1797 "peclet::flow CutcellMG::solvePCG: preconditioner produced non-finite z "
1798 "(set_pressure_strict)");
1799 return maxit;
1800 }
1801 for (; it < maxit; ++it) {
1802 matvec(Ap, p);
1803 if (meanRemovalAll_)
1804 removeMean(l0, Ap); // A preserves mean-freeness; "fine" scope trusts that
1805 const double pAp = dot(l0, p, Ap);
1806 if (!std::isfinite(pAp) || pAp <= 1e-300) {
1807 if (!std::isfinite(pAp))
1808 solveFailed_ = true; // ISSUES sweep item 6 (pAp <= 1e-300 is a CONVERGED direction)
1809 break; // keep the last finite iterate
1810 }
1811 const double alpha = rz / pAp;
1812 axpy(x, alpha, p);
1813 axpy(r, -alpha, Ap);
1814 removeMean(l0, r);
1815 const double rn = maxabs(l0, r);
1816 if (trace)
1817 printf("[mg] it %3d |r|inf=%.6e r/r0=%.4e\n", it + 1, rn, rn / r0);
1818 if (rn < rtol * rref) {
1819 ++it;
1820 break;
1821 }
1822 precond(z, r);
1823 const double rznew = dot(l0, r, z), beta = rznew / rz;
1824 if (!std::isfinite(rznew)) {
1825 solveFailed_ = true; // ISSUES sweep item 6: a breakdown, not a convergence
1826 break; // preconditioner breakdown: keep the last finite iterate
1827 }
1828 aypx(p, beta, z);
1829 rz = rznew;
1830 }
1831 }
1832 Kokkos::deep_copy(CCExec(), l0.x, x);
1833 removeMean(l0, l0.x);
1834 Kokkos::deep_copy(CCExec(), x, l0.x);
1835 return it;
1836 }
1837
1838 // --- A6 (doc/vof_step_performance_design.md §4.4, §5.6): device-resident Krylov scalars -------
1839 // The single-rank MG-PCG driver. The same recurrence as solvePCG's loop, statement for statement,
1840 // with its scalars kept on the device: the reductions land in slots of ks_ (0-d Views over one
1841 // small device array) with the same policy and functor as dot / maxabs / removeMean, alpha and
1842 // beta are formed by one-thread kernels with the same IEEE divisions, and the host reads ONE
1843 // packet {pAp, |r|inf, stop} per iteration -- what the stop test needs -- instead of five scalars
1844 // (two dots, the max, two mean-removal sums). Bit-identical to the host-scalar loop; every exit
1845 // returns the same iteration count and sets the same solveFailed_:
1846 // * pAp non-finite or <= 1e-300: x and r untouched, break without ++it (failed iff non-finite);
1847 // * r^T z non-finite: flagged on the device; the NEXT iteration's packet reports it (its update
1848 // is skipped, x and r untouched) and the index of the iteration that computed it is returned
1849 // -- or, if that was the last one, the read after the loop does;
1850 // * |r|inf < rtol r0: ++it, break.
1851 // The distributed path keeps solvePCG's host-scalar loop unchanged (a device-resident Allreduce
1852 // is not in this design).
1853 enum : int {
1854 kPAp = 0,
1855 kRn = 1,
1857 kBottomFlag = 3, // the device bottom met a non-finite scalar (read with the packet)
1858 kRz = 4,
1862 kMsum = 8
1864 static constexpr int kNSlots = 9;
1865 static constexpr int kPacket = 4; // the per-iteration host packet {pAp, rn, stop, bottomFlag}
1866 static constexpr double kBrkPAp = 1.0, kBrkRz = 2.0; // the stop codes (slot kStop)
1867 Kokkos::View<double*, CCMem> ks_; // the scalar slots
1868 Kokkos::View<long, CCMem> kcnt_; // removeMean's fluid-cell count
1869 Kokkos::View<double*, Kokkos::HostSpace> kpk_; // the host packet {pAp, rn, stop, bottomFlag}
1871 if (ks_.extent(0) == (std::size_t)kNSlots)
1872 return;
1873 ks_ = Kokkos::View<double*, CCMem>("peclet::flow::mg_krylov_scalars", kNSlots);
1874 kcnt_ = Kokkos::View<long, CCMem>("peclet::flow::mg_mean_count");
1875 kpk_ = Kokkos::View<double*, Kokkos::HostSpace>("peclet::flow::mg_krylov_packet", kPacket);
1876 }
1877 Kokkos::View<double, CCMem> slot(int k) const {
1878 return Kokkos::View<double, CCMem>(ks_.data() + k);
1879 }
1880 double readSlot(int k) const {
1881 double v = 0.0;
1882 Kokkos::deep_copy(v, slot(k));
1883 return v;
1884 }
1885 void setSlot(int k, double v) { Kokkos::deep_copy(CCExec(), slot(k), v); }
1886 // dot / maxabs into a slot: the SAME policy and functor as dot() / maxabs(), result on the
1887 // device.
1888 void dotTo(Level& lv, CCField a, CCField b, int k) {
1889 C3 e = lv.ext;
1890 const int g = lv.g;
1891 CCField aa = a, bb = b;
1892 FPV ac = lv.AC;
1893 ccReduce3(
1894 "mgdot", C3{g, g, g}, C3{e.x - g, e.y - g, e.z - g},
1895 KOKKOS_LAMBDA(int x, int y, int z, double& acc) {
1896 const long i = (long)x + (long)y * e.x + (long)z * (long)e.x * e.y;
1897 if (ac(i) > 1e-30f)
1898 acc += aa(i) * bb(i);
1899 },
1900 slot(k));
1901 }
1902 void maxabsTo(Level& lv, CCField a, int k) {
1903 C3 e = lv.ext;
1904 const int g = lv.g;
1905 CCField aa = a;
1906 FPV ac = lv.AC;
1907 ccReduce3(
1908 "mgmax", C3{g, g, g}, C3{e.x - g, e.y - g, e.z - g},
1909 KOKKOS_LAMBDA(int x, int y, int z, double& acc) {
1910 const long i = (long)x + (long)y * e.x + (long)z * (long)e.x * e.y;
1911 if (ac(i) > 1e-30f) {
1912 const double v = Kokkos::fabs(aa(i));
1913 if (v > acc)
1914 acc = v;
1915 }
1916 },
1917 Kokkos::Max<double, CCMem>(slot(k)));
1918 }
1919 // Test hook (tests/kokkos/test_pcg_breakdown.cpp): poison the pAp (which = 1) or r^T z
1920 // (which = 2) of iteration `iter` of the next single-rank PCG solves with a NaN; which = 0 (the
1921 // default) disarms it. Exercises the breakdown exits, which no healthy problem reaches.
1922 void setDebugBreakdown(int which, int iter) {
1923 dbgBrkWhich_ = which;
1924 dbgBrkIter_ = iter;
1925 }
1927 double rtol, int pre, int post, int bottom, const StarOverlay* star,
1928 int nStar, C3 nnStar) {
1929 solveFailed_ = false; // ISSUES sweep item 6: per-solve breakdown flag
1930 OverlayScope overlay(overlaySolve_, star != nullptr);
1931 pre_ = pre;
1932 post_ = post;
1933 bottom_ = bottom;
1934 ensureScalars();
1935 Level& l0 = lv_[0];
1936 Kokkos::deep_copy(CCExec(), l0.x, x);
1937 auto matvec = [&](CCField y, CCField v) {
1938 matvecOverlap(l0, y, v);
1939 if (star)
1940 starApplyDelta(y, CCConst(v), *star, nStar, nnStar, l0.ext, G, l0.ext, G, exactResidual_);
1941 };
1942 auto precond = [&](CCField zz, CCField rr) { precondVcycle(zz, rr); }; // A5
1943 matvec(Ap, x); // r = b - A x
1944 Kokkos::deep_copy(CCExec(), r, b);
1945 axpy(r, -1.0, Ap);
1946 removeMean(l0, r); // compatibility: project rhs/residual onto the range
1947 const double r0 = maxabs(l0, r); // host read (once per solve)
1948 int it = 0;
1949 const bool trace = mgDebugLevel() >= 2 && dbgSolve_ < mgDebugSolves();
1950 if (trace)
1951 printf("[mg] solve %d: r0=%.6e rtol=%.1e (pre=%d post=%d bottom=%d)\n", dbgSolve_, r0, rtol,
1952 pre, post, bottom);
1953 ++dbgSolve_;
1954 if (r0 > 0.0 && std::isfinite(r0)) {
1955 precond(z, r);
1956 Kokkos::deep_copy(CCExec(), p, z);
1957 dotTo(l0, r, z, kRz);
1958 const double rz0 = readSlot(kRz); // host read (the initial guard)
1959 if (!std::isfinite(rz0)) {
1960 solveFailed_ = true; // see solvePCG
1961 printf(
1962 "peclet::flow CutcellMG::solvePCG: preconditioner produced non-finite z; "
1963 "returning zero correction (reported as %d/%d iterations, i.e. a CAPPED solve)\n",
1964 maxit, maxit);
1965 Kokkos::deep_copy(CCExec(), x, 0.0);
1966 Kokkos::deep_copy(CCExec(), l0.x, x);
1967 if (strictPressure_)
1968 throw std::runtime_error(
1969 "peclet::flow CutcellMG::solvePCG: preconditioner produced non-finite z "
1970 "(set_pressure_strict)");
1971 return maxit;
1972 }
1973 setSlot(kStop, 0.0);
1974 auto ks = ks_;
1975 const std::size_t n = x.extent(0);
1976 CCField xx = x, rr = r, pp = p, zz = z, aa = Ap;
1977 const double brkPAp = kBrkPAp, brkRz = kBrkRz; // by value into the kernels
1978 bool exited = false;
1979 for (; it < maxit; ++it) {
1980 matvec(Ap, p);
1981 if (meanRemovalAll_)
1982 removeMean(l0, Ap); // A preserves mean-freeness; "fine" scope trusts that
1983 dotTo(l0, p, Ap, kPAp);
1984 if (dbgBrkWhich_ == 1 && it == dbgBrkIter_)
1985 setSlot(kPAp, std::numeric_limits<double>::quiet_NaN());
1986 Kokkos::parallel_for( // alpha = rz / pAp, or the pAp breakdown flag
1987 "mgpcg_alpha", Kokkos::RangePolicy<CCExec>(CCExec(), 0, 1), KOKKOS_LAMBDA(int) {
1988 const double pAp = ks(kPAp);
1989 if (ks(kStop) == 0.0) {
1990 if (Kokkos::isfinite(pAp) && pAp > 1e-300)
1991 ks(kAlpha) = ks(kRz) / pAp;
1992 else
1993 ks(kStop) = brkPAp;
1994 }
1995 });
1996 Kokkos::parallel_for( // x += alpha p; r -= alpha Ap (axpy's expressions)
1997 "mgpcg_update", Kokkos::RangePolicy<CCExec>(CCExec(), 0, n),
1998 KOKKOS_LAMBDA(std::size_t i) {
1999 if (ks(kStop) != 0.0)
2000 return;
2001 const double a = ks(kAlpha), na = -a;
2002 xx(i) += a * pp(i);
2003 rr(i) += na * aa(i);
2004 });
2005 removeMean(l0, r, /*stopGuard=*/true);
2006 maxabsTo(l0, r, kRn);
2007 Kokkos::deep_copy(kpk_, Kokkos::subview(ks_, std::make_pair(0, kPacket))); // THE read
2008 const double pAp = kpk_(0), rn = kpk_(1), stop = kpk_(2);
2009 noteBottomFlag(kpk_(3)); // a non-finite scalar in a device bottom solve
2010 if (stop == kBrkRz) { // r^T z of the previous iteration was non-finite
2011 solveFailed_ = true; // ISSUES sweep item 6: a breakdown, not a convergence
2012 --it; // report the iteration that computed it
2013 exited = true;
2014 break;
2015 }
2016 if (stop == kBrkPAp) {
2017 if (!std::isfinite(pAp))
2018 solveFailed_ = true; // ISSUES sweep item 6 (pAp <= 1e-300 is a CONVERGED direction)
2019 exited = true;
2020 break; // keep the last finite iterate
2021 }
2022 if (trace)
2023 printf("[mg] it %3d |r|inf=%.6e r/r0=%.4e\n", it + 1, rn, rn / r0);
2024 if (rn < rtol * r0) {
2025 ++it;
2026 exited = true;
2027 break;
2028 }
2029 precond(z, r);
2030 dotTo(l0, r, z, kRzNew);
2031 if (dbgBrkWhich_ == 2 && it == dbgBrkIter_)
2032 setSlot(kRzNew, std::numeric_limits<double>::quiet_NaN());
2033 Kokkos::parallel_for( // beta = r^T z_new / r^T z; rz = r^T z_new -- or the flag
2034 "mgpcg_beta", Kokkos::RangePolicy<CCExec>(CCExec(), 0, 1), KOKKOS_LAMBDA(int) {
2035 const double rznew = ks(kRzNew);
2036 if (Kokkos::isfinite(rznew)) {
2037 ks(kBeta) = rznew / ks(kRz);
2038 ks(kRz) = rznew;
2039 } else {
2040 ks(kStop) = brkRz;
2041 }
2042 });
2043 Kokkos::parallel_for( // p = z + beta p (aypx's expression)
2044 "mgaypx", Kokkos::RangePolicy<CCExec>(CCExec(), 0, n), KOKKOS_LAMBDA(std::size_t i) {
2045 if (ks(kStop) != 0.0)
2046 return;
2047 pp(i) = zz(i) + ks(kBeta) * pp(i);
2048 });
2049 }
2050 if (!exited && maxit > 0 && readSlot(kStop) == kBrkRz) {
2051 solveFailed_ = true; // the last iteration's r^T z was non-finite
2052 it = maxit - 1;
2053 }
2054 }
2055 Kokkos::deep_copy(CCExec(), l0.x, x);
2056 removeMean(l0, l0.x);
2057 Kokkos::deep_copy(CCExec(), x, l0.x);
2058 return it;
2059 }
2060
2061 // FLEXIBLE CG (Notay 2000 / Golub-Ye "inexact preconditioned CG"; the Polak-Ribiere form of
2062 // Axelsson's generalized CG), preconditioned by the SAME one symmetric V-cycle as solvePCG.
2063 //
2064 // The ONLY difference from solvePCG is the beta recurrence: Fletcher-Reeves
2065 // beta = r_{k+1}^T z_{k+1} / (r_k^T z_k)
2066 // is replaced by Polak-Ribiere
2067 // beta = r_{k+1}^T (z_{k+1} - z_k) / (r_k^T z_k),
2068 // at the cost of one extra stored vector (z_k) and one extra global dot per iteration. The two
2069 // forms are ALGEBRAICALLY IDENTICAL when the preconditioner is a fixed SPD operator, because
2070 // then r_{k+1} is M^{-1}A-orthogonal to z_k, i.e. r_{k+1}^T z_k = 0 exactly -- so on a healthy
2071 // problem FCG must reproduce PCG's iteration count (that equality is the sanity gate). When the
2072 // preconditioner is NOT symmetric w.r.t. the fine operator, that orthogonality is lost, the
2073 // Fletcher-Reeves numerator is contaminated by a term CG has no right to, and the iteration
2074 // stalls; Polak-Ribiere subtracts exactly that term, which is why FCG converging where PCG
2075 // stalls is a DIAGNOSIS ("the preconditioner is nonsymmetric") and not merely a fix.
2076 //
2077 // Everything else -- matvec (incl. the optional star overlay), preconditioner, mean removal,
2078 // the maxabs(r) < rtol*r0 stopping estimate, the breakdown guards, the final mean removal -- is
2079 // identical to solvePCG line for line. Keep the two in sync if either is ever changed.
2080 // Scratch: solvePCG's five level-0 fields plus `zp` (the previous preconditioned residual).
2082 int maxit, double rtol, int pre, int post, int bottom,
2083 const StarOverlay* star = nullptr, int nStar = 0, C3 nnStar = C3{0, 0, 0}) {
2084 solveFailed_ = false; // ISSUES sweep item 6: per-solve breakdown flag
2085 OverlayScope overlay(overlaySolve_, star != nullptr);
2086 pre_ = pre;
2087 post_ = post;
2088 bottom_ = bottom;
2089 Level& l0 = lv_[0];
2090 Kokkos::deep_copy(CCExec(), l0.x, x);
2091 auto matvec = [&](CCField y, CCField v) {
2092 matvecOverlap(l0, y, v);
2093 if (star)
2094 starApplyDelta(y, CCConst(v), *star, nStar, nnStar, l0.ext, G, l0.ext, G, exactResidual_);
2095 };
2096 auto precond = [&](CCField zz, CCField rr) { precondVcycle(zz, rr); }; // A5
2097 matvec(Ap, x); // r = b - A x
2098 Kokkos::deep_copy(CCExec(), r, b);
2099 axpy(r, -1.0, Ap);
2100 removeMean(l0, r); // compatibility: project rhs/residual onto the range
2101 const double r0 = maxabs(l0, r);
2102 // The stop reference: r0 (the default), or the caller's FULL right-hand-side norm when a
2103 // stop reference is set (setStopReference; only the balanced-force projection sets it).
2104 // With it unset rref IS r0, so `rtol * rref` is bit-for-bit the old test.
2105 const double rref = stopRef_ > 0.0 ? stopRef_ : r0;
2106 int it = 0;
2107 int dbgRank = 0;
2108#ifdef PECLET_FLOW_MPI
2109 if (distributed_)
2110 MPI_Comm_rank(comm_, &dbgRank);
2111#endif
2112 const bool trace = mgDebugLevel() >= 2 && dbgSolve_ < mgDebugSolves() && dbgRank == 0;
2113 if (trace)
2114 printf("[mg] fcg solve %d: r0=%.6e rtol=%.1e (pre=%d post=%d bottom=%d)\n", dbgSolve_, r0,
2115 rtol, pre, post, bottom);
2116 ++dbgSolve_;
2117 if (r0 > 0.0 && std::isfinite(r0)) {
2118 precond(z, r);
2119 Kokkos::deep_copy(CCExec(), p, z);
2120 double rz = dot(l0, r, z);
2121 if (!std::isfinite(rz)) {
2122 solveFailed_ = true; // ISSUES sweep item 6 -- see solvePCG for the mechanism
2123 printf(
2124 "peclet::flow CutcellMG::solveFCG: preconditioner produced non-finite z; "
2125 "returning zero correction (reported as %d/%d iterations, i.e. a CAPPED solve)\n",
2126 maxit, maxit);
2127 Kokkos::deep_copy(CCExec(), x, 0.0);
2128 Kokkos::deep_copy(CCExec(), l0.x, x);
2129 if (strictPressure_)
2130 throw std::runtime_error(
2131 "peclet::flow CutcellMG::solveFCG: preconditioner produced non-finite z "
2132 "(set_pressure_strict)");
2133 return maxit;
2134 }
2135 for (; it < maxit; ++it) {
2136 matvec(Ap, p);
2137 if (meanRemovalAll_)
2138 removeMean(l0, Ap); // A preserves mean-freeness; "fine" scope trusts that
2139 const double pAp = dot(l0, p, Ap);
2140 if (!std::isfinite(pAp) || pAp <= 1e-300) {
2141 if (!std::isfinite(pAp))
2142 solveFailed_ = true; // ISSUES sweep item 6 (pAp <= 1e-300 is a CONVERGED direction)
2143 break; // keep the last finite iterate
2144 }
2145 const double alpha = rz / pAp;
2146 axpy(x, alpha, p);
2147 axpy(r, -alpha, Ap);
2148 removeMean(l0, r);
2149 const double rn = maxabs(l0, r);
2150 if (trace)
2151 printf("[mg] it %3d |r|inf=%.6e r/r0=%.4e\n", it + 1, rn, rn / r0);
2152 if (rn < rtol * rref) {
2153 ++it;
2154 break;
2155 }
2156 Kokkos::deep_copy(CCExec(), zp, z); // z_k, before the preconditioner overwrites it
2157 precond(z, r);
2158 const double rznew = dot(l0, r, z), rzcross = dot(l0, r, zp);
2159 if (!std::isfinite(rznew) || !std::isfinite(rzcross)) {
2160 solveFailed_ = true; // ISSUES sweep item 6: a breakdown, not a convergence
2161 break; // preconditioner breakdown: keep the last finite iterate
2162 }
2163 const double beta = (rznew - rzcross) / rz; // Polak-Ribiere: r^T (z_{k+1} - z_k) / r^T z
2164 // DIAGNOSTIC (PECLET_FLOW_MG_DEBUG>=2): `pr` is |r_{k+1}^T z_k| / |r_{k+1}^T z_{k+1}| --
2165 // the term Fletcher-Reeves keeps and Polak-Ribiere removes. With a preconditioner that is
2166 // symmetric w.r.t. the fine operator this is EXACTLY zero in exact arithmetic, so a
2167 // measured pr ~ 1e-14 says "the V-cycle is symmetric here (and FCG == PCG by construction)"
2168 // while pr = O(1) says "it is not" -- a direct read-out of the hypothesis, per iteration,
2169 // that needs no extra solve.
2170 if (trace)
2171 printf("[mg] it %3d beta=%.6e pr=%.3e (|r^T z_k| / |r^T z_{k+1}|)\n", it + 1, beta,
2172 rznew != 0.0 ? std::abs(rzcross / rznew) : 0.0);
2173 aypx(p, beta, z);
2174 rz = rznew;
2175 }
2176 }
2177 Kokkos::deep_copy(CCExec(), l0.x, x);
2178 removeMean(l0, l0.x);
2179 Kokkos::deep_copy(CCExec(), x, l0.x);
2180 return it;
2181 }
2182
2183 // BiCGStab preconditioned by one symmetric V-cycle, for the NONSYMMETRIC ghost-projection
2184 // operator A = (binary-openness 7-point op) + (per-row overlay delta, gpApplyDelta). The MG
2185 // hierarchy/preconditioner only ever sees the symmetric binary surrogate its levels were built
2186 // from (setOpenness); the overlay enters the fine-level matvec only. Same breakdown guards +
2187 // constant-mode (mean) removal as solvePCG, plus a stagnation guard: the nonsymmetric system's
2188 // left null vector is NOT exactly the constants, so the attainable residual has a small
2189 // compatibility floor — stop when no progress instead of burning maxit. Scratch: 7 level-0
2190 // fields from the caller. Returns the iteration count.
2191 // Distributed: the reductions/mean removal already Allreduce and the V-cycle is MPI-folded; the
2192 // fine-level matvec is the one gp-specific piece. The overlay couplings reach +/-2 but the MG
2193 // block only has a g=1 halo, so the caller passes a g=2 staging field (xg2, on its ext2 block)
2194 // + that block's halo: stage q's inner cells there, exchange the 2-deep halo once, read the g=1
2195 // halo back from the staged copy (one exchange serves both the 7-point op and the overlay), and
2196 // apply the overlay in ghost mode. Single-rank (h2 == nullptr) is byte-identical to before.
2198 CCField z, CCField z2, int maxit, double rtol, int pre, int post, int bottom,
2199 const GpOverlayReal<MReal>& ov, int nOv, C3 nn
2201 ,
2202 CCField xg2 = CCField(), GridHalo<double>* h2 = nullptr, C3 ext2 = C3{0, 0, 0}
2203#endif
2204 ) {
2205 solveFailed_ = false; // ISSUES sweep item 6: per-solve breakdown flag
2206 OverlayScope overlay(overlaySolve_, true); // A3: the gp overlay keeps today's fills
2207 pre_ = pre;
2208 post_ = post;
2209 bottom_ = bottom;
2210 Level& l0 = lv_[0];
2211 auto matvec = [&](CCField y, CCField q) {
2212#ifdef PECLET_FLOW_MPI
2213 if (distributed_ && h2) {
2214 stageG2(l0, q, xg2, ext2); // inner cells g=1 block -> g=2 block
2215 h2->exchange(xg2); // 2-deep halo (cross-rank + periodic)
2216 unstageG2(l0, q, xg2); // whole l0 block back (fills q's g=1 halo — no 2nd exchange)
2218 applyCutcellOpFace(y, CCConst(q), FPC(l0.AC), FPC(l0.AFX), FPC(l0.AFY), FPC(l0.AFZ), l0.ext,
2219 G);
2220 gpApplyDelta(y, CCConst(xg2), ov, nOv, nn, l0.ext, G, ext2, 2, /*useGhost=*/true);
2221 return;
2222 }
2223#endif
2224 fill(l0, q);
2225 applyOutflowGhost(l0, q);
2226 applyCutcellOpFace(y, CCConst(q), FPC(l0.AC), FPC(l0.AFX), FPC(l0.AFY), FPC(l0.AFZ), l0.ext,
2227 G);
2228 gpApplyDelta(y, CCConst(q), ov, nOv, nn, l0.ext, G, l0.ext, G);
2229 };
2230 auto precond = [&](CCField zz, CCField rr) {
2231 Kokkos::deep_copy(CCExec(), l0.rhs, rr);
2232 Kokkos::deep_copy(CCExec(), l0.x, 0.0);
2233 vcycle(0, /*sym=*/true);
2234 Kokkos::deep_copy(CCExec(), zz, l0.x);
2235 };
2236 matvec(t, x); // r = b - A x (t as scratch)
2237 Kokkos::deep_copy(CCExec(), r, b);
2238 axpy(r, -1.0, t);
2239 removeMean(l0, r);
2240 Kokkos::deep_copy(CCExec(), rh, r); // shadow residual r^ = r_0
2241 const double r0n = maxabs(l0, r);
2242 int it = 0;
2243 if (r0n > 0.0 && std::isfinite(r0n)) {
2244 double rho = 1.0, alpha = 1.0, omega = 1.0;
2245 double best = r0n;
2246 int lastImprove = 0;
2247 Kokkos::deep_copy(CCExec(), p, 0.0);
2248 Kokkos::deep_copy(CCExec(), v, 0.0);
2249 for (; it < maxit; ++it) {
2250 const double rhoNew = dot(l0, rh, r);
2251 if (!std::isfinite(rhoNew) || std::fabs(rhoNew) < 1e-300)
2252 break; // (rh, r) breakdown: keep the last finite iterate
2253 const double beta = (rhoNew / rho) * (alpha / omega);
2254 rho = rhoNew;
2255 axpy(p, -omega, v); // p = r + beta (p - omega v)
2256 aypx(p, beta, r);
2257 precond(z, p);
2258 matvec(v, z);
2259 removeMean(l0, v);
2260 const double rhv = dot(l0, rh, v);
2261 if (!std::isfinite(rhv) || std::fabs(rhv) < 1e-300)
2262 break;
2263 alpha = rho / rhv;
2264 axpy(r, -alpha, v); // r <- s = r - alpha v
2265 removeMean(l0, r);
2266 double rn = maxabs(l0, r);
2267 if (!std::isfinite(rn))
2268 break;
2269 if (rn < rtol * r0n) {
2270 axpy(x, alpha, z);
2271 ++it;
2272 break;
2273 }
2274 precond(z2, r);
2275 matvec(t, z2);
2276 removeMean(l0, t);
2277 const double tt = dot(l0, t, t);
2278 if (!std::isfinite(tt) || tt < 1e-300) {
2279 axpy(x, alpha, z); // omega breakdown: take the alpha half-step and stop
2280 break;
2281 }
2282 omega = dot(l0, t, r) / tt;
2283 if (!std::isfinite(omega) || std::fabs(omega) < 1e-300) {
2284 axpy(x, alpha, z);
2285 break;
2286 }
2287 axpy(x, alpha, z);
2288 axpy(x, omega, z2);
2289 axpy(r, -omega, t);
2290 removeMean(l0, r);
2291 rn = maxabs(l0, r);
2292 if (!std::isfinite(rn))
2293 break;
2294 if (rn < rtol * r0n) {
2295 ++it;
2296 break;
2297 }
2298 if (rn < 0.999 * best) {
2299 best = rn;
2300 lastImprove = it;
2301 } else if (it - lastImprove > 30) {
2302 ++it; // compatibility-floor stagnation: accept the best-so-far level
2303 break;
2304 }
2305 }
2306 }
2307 Kokkos::deep_copy(CCExec(), l0.x, x);
2308 removeMean(l0, l0.x);
2309 Kokkos::deep_copy(CCExec(), x, l0.x);
2310 return it;
2311 }
2312
2313 public: // (public for nvcc extended-lambda rule)
2314 // Per-level V-cycle wall time (PECLET_FLOW_MG_DEBUG>=3; HOST backends only — no device
2315 // fence, so on CUDA the numbers are launch times, not kernel times). Answers "how much of
2316 // the solve is spent on the small coarse levels", i.e. whether coarse-level launch
2317 // overhead is worth chasing.
2318#ifdef PECLET_FLOW_MPI
2319
2320 // Telescope data movement (host-staged; the stage is a coarse level, i.e. small), through the
2321 // stage's core RedistributeTopology. Gather: every member's INNER cells land in the merged
2322 // block at (member origin - merged origin + g) on the group root (core's forward: a group
2323 // Gatherv). Scatter-add: the inverse (core's backward: a group Scatterv, which OVERWRITES its
2324 // landing buffer), then each member ADDS the received box into `dst`'s inner cells (prolongAdd
2325 // is additive — flow's arithmetic, not the movement's). Pure data movement, bitwise.
2326 void teleGather(const Level& lv, CCField src, CCField dst) {
2327 Telescope& T = *lv.tele;
2328 auto hs = Kokkos::create_mirror_view(src);
2329 Kokkos::deep_copy(hs, src);
2330 if (!T.root()) {
2331 T.move.forward({hs.data()}, {});
2332 return;
2333 }
2334 auto hd = Kokkos::create_mirror_view(dst);
2335 T.move.forward({hs.data()}, {hd.data()});
2336 Kokkos::deep_copy(dst, hd);
2337 }
2338 // WO-R2 outflow coefficient across a telescope point: the HIGH-side outflow coefficient of a
2339 // level lives on the first ghost index beyond the inner block along the outflow axis (a plane
2340 // teleGather's inner-only pack leaves behind). Gather that plane from the members touching the
2341 // global +face into the merged stage buffer's own ghost plane, so the coarse level coarsens its
2342 // boundary coefficient from the stage exactly as it would from a finer level in place.
2343 void teleGatherOutflowPlanes(const Level& lv) {
2344 if (!outflowCoeff_)
2345 return;
2346 CCField src[3] = {lv.ox, lv.oy, lv.oz};
2347 CCField dst[3] = {lv.tele->ox, lv.tele->oy, lv.tele->oz};
2348 for (int a = 0; a < 3; ++a)
2349 if (bc_[2 * a + 1] == 3) {
2350 if (lv.tele->repartition())
2351 teleGatherPlaneRepartition(lv, src[a], dst[a], a);
2352 else
2353 teleGatherPlane(lv, src[a], dst[a], a);
2354 }
2355 }
2356 // The same plane across a REPARTITION stage, where there are no merge groups: a source block
2357 // touching the global +face sends each target block touching it the part of its plane that lies
2358 // in the target's transverse (b, c) rectangle — the box intersections, computed from the two
2359 // replicated decompositions, exactly as core's RedistributeTopology does for the inner cells.
2360 // One MPI_Alltoallv on the level's communicator (every rank takes part; most counts are zero),
2361 // cells in (c, b) order on both sides; the target lands them on its stage buffer's own ghost
2362 // plane. Pure data movement.
2363 void teleGatherPlaneRepartition(const Level& lv, CCField src, CCField dst, int a) {
2364 Telescope& T = *lv.tele;
2365 using Blk = peclet::core::decomp::Block<3>;
2366 const int b = (a + 1) % 3, c = (a + 2) % 3;
2367 const long gd[3] = {lv.gdim.x, lv.gdim.y, lv.gdim.z};
2368 int rank = 0, np = 1;
2369 MPI_Comm_rank(T.comm.parent, &rank);
2370 MPI_Comm_size(T.comm.parent, &np);
2371 auto touches = [&](const Blk& k) { return (long)(k.origin[a] + k.size[a]) == gd[a]; };
2372 // transverse overlap [lo, hi) of two blocks on axes b and c; false when empty
2373 auto overlap = [&](const Blk& p, const Blk& q, long lo[2], long hi[2]) {
2374 const int ax[2] = {b, c};
2375 for (int e = 0; e < 2; ++e) {
2376 lo[e] = (long)std::max(p.origin[ax[e]], q.origin[ax[e]]);
2377 hi[e] = (long)std::min(p.origin[ax[e]] + p.size[ax[e]], q.origin[ax[e]] + q.size[ax[e]]);
2378 if (hi[e] <= lo[e])
2379 return false;
2380 }
2381 return true;
2382 };
2383 std::vector<int> sc((std::size_t)np, 0), sd((std::size_t)np, 0), rc((std::size_t)np, 0),
2384 rd((std::size_t)np, 0);
2385 std::vector<double> sb, rb;
2386 const Blk me = T.srcDec.block((std::size_t)rank);
2387 if (touches(me)) {
2388 const int g = lv.g;
2389 const int ext[3] = {lv.ext.x, lv.ext.y, lv.ext.z};
2390 const long st[3] = {1, (long)lv.ext.x, (long)lv.ext.x * lv.ext.y};
2391 auto hs = Kokkos::create_mirror_view(src);
2392 Kokkos::deep_copy(hs, src);
2393 const long pa = (long)(ext[a] - g) * st[a];
2394 for (std::size_t t = 0; t < T.dstDec.numBlocks(); ++t) {
2395 long lo[2], hi[2];
2396 const Blk tb = T.dstDec.block(t);
2397 if (!touches(tb) || !overlap(me, tb, lo, hi))
2398 continue;
2399 sd[t] = (int)sb.size();
2400 for (long k = lo[1]; k < hi[1]; ++k)
2401 for (long j = lo[0]; j < hi[0]; ++j)
2402 sb.push_back(hs(pa + (j - (long)me.origin[b] + g) * st[b] +
2403 (k - (long)me.origin[c] + g) * st[c]));
2404 sc[t] = (int)sb.size() - sd[t];
2405 }
2406 }
2407 const bool land = T.root() && touches(T.dstDec.block((std::size_t)rank));
2408 if (land) {
2409 const Blk tb = T.dstDec.block((std::size_t)rank);
2410 int n = 0;
2411 for (std::size_t s2 = 0; s2 < T.srcDec.numBlocks(); ++s2) {
2412 long lo[2], hi[2];
2413 const Blk sk = T.srcDec.block(s2);
2414 if (!touches(sk) || !overlap(sk, tb, lo, hi))
2415 continue;
2416 rd[s2] = n;
2417 rc[s2] = (int)((hi[0] - lo[0]) * (hi[1] - lo[1]));
2418 n += rc[s2];
2419 }
2420 rb.resize((std::size_t)n);
2421 }
2422 MPI_Alltoallv(sb.data(), sc.data(), sd.data(), MPI_DOUBLE, rb.data(), rc.data(), rd.data(),
2423 MPI_DOUBLE, T.comm.parent);
2424 if (!land)
2425 return;
2426 const Blk tb = T.dstDec.block((std::size_t)rank);
2427 const long mst[3] = {1, (long)T.mExt.x, (long)T.mExt.x * T.mExt.y};
2428 const int mext[3] = {T.mExt.x, T.mExt.y, T.mExt.z};
2429 auto hd = Kokkos::create_mirror_view(dst);
2430 Kokkos::deep_copy(hd, dst);
2431 const long pa = (long)(mext[a] - T.g) * mst[a];
2432 for (std::size_t s2 = 0; s2 < T.srcDec.numBlocks(); ++s2) {
2433 if (rc[s2] == 0)
2434 continue;
2435 long lo[2], hi[2];
2436 overlap(T.srcDec.block(s2), tb, lo, hi);
2437 const double* q = rb.data() + rd[s2];
2438 for (long k = lo[1]; k < hi[1]; ++k)
2439 for (long j = lo[0]; j < hi[0]; ++j)
2440 hd(pa + (j - (long)tb.origin[b] + T.g) * mst[b] +
2441 (k - (long)tb.origin[c] + T.g) * mst[c]) = *q++;
2442 }
2443 Kokkos::deep_copy(dst, hd);
2444 }
2445 void teleGatherPlane(const Level& lv, CCField src, CCField dst, int a) {
2446 Telescope& T = *lv.tele;
2447 const int g = lv.g, b = (a + 1) % 3, c = (a + 2) % 3;
2448 const int ext[3] = {lv.ext.x, lv.ext.y, lv.ext.z},
2449 inn[3] = {lv.inner.x, lv.inner.y, lv.inner.z};
2450 const long st[3] = {1, (long)lv.ext.x, (long)lv.ext.x * lv.ext.y};
2451 const bool mine = touchesGlobalFace(lv, 2 * a + 1);
2452 // pack my plane (ghost index ext[a]-g along a; inner ranges along b, c) if I touch the +face
2453 std::vector<double> sb;
2454 if (mine) {
2455 auto hs = Kokkos::create_mirror_view(src);
2456 Kokkos::deep_copy(hs, src);
2457 sb.resize((std::size_t)inn[b] * inn[c]);
2458 const long pa = (long)(ext[a] - g) * st[a];
2459 for (int k = 0; k < inn[c]; ++k)
2460 for (int j = 0; j < inn[b]; ++j)
2461 sb[(std::size_t)j + (std::size_t)k * inn[b]] =
2462 hs(pa + (long)(j + g) * st[b] + (long)(k + g) * st[c]);
2463 }
2464 // the root knows which members touch the face from their block boxes (replicated)
2465 std::vector<int> counts, displs;
2466 std::vector<double> rb;
2467 const int gd[3] = {lv.gdim.x, lv.gdim.y, lv.gdim.z};
2468 if (T.root()) {
2469 int disp = 0;
2470 for (int m = 0; m < T.nMembers; ++m) {
2471 const int mo[3] = {T.memO[m].x, T.memO[m].y, T.memO[m].z};
2472 const int ms[3] = {T.memS[m].x, T.memS[m].y, T.memS[m].z};
2473 const int n = (mo[a] + ms[a] == gd[a]) ? ms[b] * ms[c] : 0;
2474 counts.push_back(n);
2475 displs.push_back(disp);
2476 disp += n;
2477 }
2478 rb.resize((std::size_t)disp);
2479 }
2480 MPI_Gatherv(sb.data(), (int)sb.size(), MPI_DOUBLE, T.root() ? rb.data() : nullptr,
2481 T.root() ? counts.data() : nullptr, T.root() ? displs.data() : nullptr, MPI_DOUBLE,
2482 0, T.comm.group);
2483 if (!T.root())
2484 return;
2485 const int mext[3] = {T.mExt.x, T.mExt.y, T.mExt.z};
2486 const long mst[3] = {1, (long)T.mExt.x, (long)T.mExt.x * T.mExt.y};
2487 const int mog[3] = {T.mOg.x, T.mOg.y, T.mOg.z};
2488 auto hd = Kokkos::create_mirror_view(dst);
2489 Kokkos::deep_copy(hd, dst);
2490 const long pa = (long)(mext[a] - T.g) * mst[a];
2491 for (int m = 0; m < T.nMembers; ++m) {
2492 if (counts[m] == 0)
2493 continue;
2494 const int mo[3] = {T.memO[m].x, T.memO[m].y, T.memO[m].z};
2495 const int ms[3] = {T.memS[m].x, T.memS[m].y, T.memS[m].z};
2496 const double* q = rb.data() + displs[m];
2497 for (int k = 0; k < ms[c]; ++k)
2498 for (int j = 0; j < ms[b]; ++j)
2499 hd(pa + (long)(mo[b] - mog[b] + j + T.g) * mst[b] +
2500 (long)(mo[c] - mog[c] + k + T.g) * mst[c]) =
2501 q[(std::size_t)j + (std::size_t)k * ms[b]];
2502 }
2503 Kokkos::deep_copy(dst, hd);
2504 }
2505 void teleScatterAdd(const Level& lv, CCField src, CCField dst) {
2506 Telescope& T = *lv.tele;
2507 const int g = lv.g;
2508 if (T.root()) {
2509 auto hs = Kokkos::create_mirror_view(src);
2510 Kokkos::deep_copy(hs, src);
2511 T.move.backward({hs.data()}, {T.back.data()});
2512 } else {
2513 T.move.backward({}, {T.back.data()});
2514 }
2515 auto hd = Kokkos::create_mirror_view(dst);
2516 Kokkos::deep_copy(hd, dst);
2517 for (int k = 0; k < lv.inner.z; ++k)
2518 for (int j = 0; j < lv.inner.y; ++j)
2519 for (int i = 0; i < lv.inner.x; ++i) {
2520 const long id =
2521 (long)(i + g) + (long)(j + g) * lv.ext.x + (long)(k + g) * (long)lv.ext.x * lv.ext.y;
2522 hd(id) += T.back[(std::size_t)id];
2523 }
2524 Kokkos::deep_copy(dst, hd);
2525 }
2526#endif
2527 // A5 (§5.5): z = M r with M one symmetric V-cycle, run IN PLACE on the caller's vectors: level
2528 // 0's `rhs` and `x` are rebound to `rr` and `zz` (shallow View assignment) for the cycle and
2529 // restored after it, replacing the `rhs <- r`, `x <- 0`, `z <- x` full-field copies. Relies on
2530 // level 0 never WRITING `rhs` (only a communication-avoiding level exchanges its rhs, and those
2531 // are coarse).
2533 Level& l0 = lv_[0];
2534 assert(!l0.caOk && "A5: level 0 must not exchange (write) its rhs");
2535 struct Restore {
2536 Level& l;
2537 CCField rhs, x;
2538 ~Restore() {
2539 l.rhs = rhs;
2540 l.x = x;
2541 }
2542 } restore{l0, l0.rhs, l0.x};
2543 l0.rhs = rr;
2544 l0.x = zz;
2545 Kokkos::deep_copy(CCExec(), zz, 0.0);
2546 vcycle(0, /*sym=*/true);
2547 }
2548 void vcycle(int L, bool sym) {
2549 if (mgDebugLevel() >= 3) {
2550 const auto t0 = std::chrono::steady_clock::now();
2551 vcycleImpl(L, sym);
2552 lvTime_.resize(lv_.size(), 0.0);
2553 lvTime_[L] += std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count();
2554 if (L == 0 && ++lvCycles_ % 50 == 0) {
2555 double tot = 0;
2556 for (std::size_t i = 0; i < lvTime_.size(); ++i)
2557 tot += (i + 1 < lvTime_.size() ? lvTime_[i] - lvTime_[i + 1] : lvTime_[i]);
2558 printf("[mg] level times over %d V-cycles (total %.3f s):\n", lvCycles_, tot);
2559 for (std::size_t i = 0; i < lvTime_.size(); ++i) {
2560 const double self = (i + 1 < lvTime_.size() ? lvTime_[i] - lvTime_[i + 1] : lvTime_[i]);
2561 printf("[mg] L%zu %5dx%5dx%5d self %7.3f s (%5.1f%%)\n", i, lv_[i].inner.x,
2562 lv_[i].inner.y, lv_[i].inner.z, self, 100.0 * self / (tot + 1e-30));
2563 }
2564 fflush(stdout);
2565 }
2566 return;
2567 }
2568 vcycleImpl(L, sym);
2569 }
2570 void vcycleImpl(int L, bool sym) {
2571 Level& lv = lv_[L];
2572 // The bottom is the last level THIS rank holds that is not a telescope point: a rank idling
2573 // below a telescope point ends its lv_ at that point and must take the transition branch
2574 // (gather, skip, scatter), never the bottom solve.
2575 bool isBottom = (L + 1 == (int)lv_.size());
2576#ifdef PECLET_FLOW_MPI
2577 if (lv.tele)
2578 isBottom = false;
2579#endif
2580 if (isBottom) {
2581 if (bottomSolver_ == kBottomDirect) {
2582 if (const char* why = directBottomIneligible())
2583 throw std::runtime_error(
2584 std::string("set_pressure_bottom_solver('direct'): not eligible here: ") + why);
2585 }
2586 if (agglomerateBottom()) {
2587 if (bottomSolver_ != kBottomAlgebraic && directBottomIneligible() == nullptr)
2588 directBottomSolve(); // §13: FCG + the FP32 direct factor (device, one launch)
2589 else
2591 lv); // agglomerated mesh-agnostic coarse solve (decomposition-agnostic)
2592 } else
2593 smooth(lv, bottom_, false);
2594 if (meanRemovalAll_)
2595 removeMean(lv, lv.x);
2596 return;
2597 }
2598 smooth(lv, pre_, false);
2599 // Refresh the halo before the residual. The smoother exchanges BEFORE each color sweep, so on
2600 // return the ghosts are one color-update stale: the residual — and hence the restricted coarse
2601 // rhs — is wrong on the block-boundary shell. That perturbation is proportional to the block
2602 // SURFACE, so it made the V-cycle's convergence rate decomposition-dependent: fat blocks (few
2603 // ranks) needed measurably more Krylov iterations than thin ones on the SAME grid (768x640x384
2604 // 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
2605 // flat 4.0 with the refresh). Distributed: overlap the exchange with the interior residual
2606 // (same interior/shell split as the smoother); single-rank: the periodic wrap copy.
2607 auto fullResidual = [&] {
2608 residualCutcellFace(lv.res, CCConst(lv.x), CCConst(lv.rhs), FPC(lv.AC), FPC(lv.AFX),
2609 FPC(lv.AFY), FPC(lv.AFZ), lv.ext, lv.g);
2610 };
2611#ifdef PECLET_FLOW_MPI
2612 if (distributed_) {
2613 const int g = lv.g;
2614 const C3 lo{g + 1, g + 1, g + 1};
2615 const C3 hi{lv.ext.x - g - 1, lv.ext.y - g - 1, lv.ext.z - g - 1};
2616 lv.dev->exchangeBegin(lv.x);
2617 residualCutcellBoxFace(lv.res, CCConst(lv.x), CCConst(lv.rhs), FPC(lv.AC), FPC(lv.AFX),
2618 FPC(lv.AFY), FPC(lv.AFZ), lv.ext, lo, hi, C3{0, 0, 0}, C3{0, 0, 0});
2619 lv.dev->exchangeEnd(lv.x);
2620 applyOutflowGhost(lv, lv.x, g);
2621 residualCutcellBoxFace(lv.res, CCConst(lv.x), CCConst(lv.rhs), FPC(lv.AC), FPC(lv.AFX),
2622 FPC(lv.AFY), FPC(lv.AFZ), lv.ext, C3{g, g, g},
2623 C3{lv.ext.x - g, lv.ext.y - g, lv.ext.z - g}, lo, hi);
2624 } else
2625#endif
2626 {
2627 if (fusedWrapReads()) { // A3: the residual reads the periodic wrap directly, no fill
2628 residualCutcellFaceWrap(lv.res, CCConst(lv.x), CCConst(lv.rhs), FPC(lv.AC), FPC(lv.AFX),
2629 FPC(lv.AFY), FPC(lv.AFZ), lv.ext, lv.inner, lv.g);
2630 } else {
2631 fill(lv, lv.x); // single-rank: the periodic wrap copy
2632 applyOutflowGhost(lv, lv.x, lv.g);
2633 fullResidual();
2634 }
2635 }
2636#ifdef PECLET_FLOW_MPI
2637 if (lv.tele) {
2638 // Telescoped transition: the residual goes DOWN at this level's own resolution onto the group
2639 // root (the merged block is coarsenable; this rank's block is not), the roots restrict /
2640 // recurse / prolong on the sub-communicator, and the correction comes back the same way.
2641 Telescope& T = *lv.tele;
2642 teleGather(lv, lv.res, T.res);
2643 if (T.root()) {
2644 Level& cs = lv_[L + 1];
2645 restrictAvg(cs.rhs, CCConst(T.res), cs.ext, T.mExt, cs.g, T.g, cs.inner, lv.ratio);
2646 Kokkos::deep_copy(CCExec(), cs.x, 0.0);
2647 vcycle(L + 1, sym);
2648 fill(cs, cs.x);
2649 applyOutflowGhost(cs, cs.x, cs.g);
2650 applyNeumannGhost(cs, cs.x, cs.g);
2651 Kokkos::deep_copy(CCExec(), T.x, 0.0);
2652 prolongAdd(T.x, CCConst(cs.x), T.mExt, cs.ext, T.g, cs.g, T.mInner, lv.ratio);
2653 }
2654 teleScatterAdd(lv, T.x, lv.x);
2655 } else
2656#endif
2657 {
2658 Level& cs = lv_[L + 1];
2659 if (!distributed_) { // A5: the restriction zeroes cs.x's inner cells itself
2660 restrictAvgZeroX(cs.rhs, cs.x, CCConst(lv.res), cs.ext, lv.ext, cs.g, lv.g, cs.inner,
2661 lv.ratio);
2662 } else {
2663 restrictAvg(cs.rhs, CCConst(lv.res), cs.ext, lv.ext, cs.g, lv.g, cs.inner, lv.ratio);
2664 Kokkos::deep_copy(CCExec(), cs.x, 0.0);
2665 }
2666 vcycle(L + 1, sym);
2667 fill(cs, cs.x);
2668 applyOutflowGhost(cs, cs.x, cs.g);
2669 // The trilinear prolongation reads the coarse ghost with weight 1/4 regardless of openness,
2670 // so a domain-BC face needs its real ghost policy here (Dirichlet 0 above, Neumann
2671 // zero-gradient below) instead of the periodic wrap fill() just wrote. See applyNeumannGhost.
2672 applyNeumannGhost(cs, cs.x, cs.g);
2673 prolongAdd(lv.x, CCConst(cs.x), lv.ext, cs.ext, lv.g, cs.g, lv.inner, lv.ratio);
2674 }
2675 smooth(lv, post_, /*reverse=*/sym);
2676 if (meanRemovalAll_ || L == 0)
2677 removeMean(lv, lv.x);
2678 }
2679 // Communication-avoiding smoothing on this level? Needs the width-2 topology (caOk), and the
2680 // periodic/IBM operator — with domain BCs the ring rows would need post-BC ghost openness the
2681 // exchange does not deliver, so those keep the per-colour exchange.
2682 bool caSmooth(const Level& lv) const { return distributed_ && lv.caOk && !hasBC_; }
2683 // A3 (§5.4) eligibility of the fused periodic-wrap reads: single rank, no outflow face (whose
2684 // Dirichlet ghost is not a copy), and no overlay on the current solve (the star /
2685 // ghost-projection paths keep today's fills). The smoother additionally needs every inner
2686 // dimension even (see cutcellSmoothColorFaceWrap); the read-only residual and matvec need no
2687 // parity.
2688 bool fusedWrapReads() const { return !distributed_ && !hasOutflow_ && !overlaySolve_; }
2689 bool fusedWrapSmooth(const Level& lv) const {
2690 return fusedWrapReads() && lv.inner.x % 2 == 0 && lv.inner.y % 2 == 0 && lv.inner.z % 2 == 0;
2691 }
2692 // Marks a solve that carries an overlay (star couplings / the ghost-projection BiCGStab) for its
2693 // whole duration, every exit path included.
2695 bool& flag;
2696 OverlayScope(bool& f, bool on) : flag(f) { flag = on; }
2697 ~OverlayScope() { flag = false; }
2698 };
2699 void smooth(Level& lv, int sweeps, bool reverse) {
2700 const C3 og = parityOg(lv); // red-black parity origin ({0,0,0} single-rank)
2701#ifdef PECLET_FLOW_MPI
2702 if (caSmooth(lv)) {
2703 // Communication-avoiding pair: ONE 2-deep exchange per red-black pair instead of a 1-deep
2704 // exchange per colour. The first colour overlaps its exchange with the interior sweep, then
2705 // sweeps the boundary shell PLUS the 1-deep ghost ring — redundantly recomputing the
2706 // neighbour's boundary cells from the same operands the neighbour uses (2-deep x ghosts,
2707 // ring rows of the operator and rhs are exchanged/assembled bit-identical), so the ring
2708 // values come out equal to what a fresh exchange would deliver. The second colour then
2709 // sweeps with NO exchange: its boundary cells read only first-colour ring cells (a colour
2710 // never reads its own colour). Bit-identical to the per-colour exchange at half the events.
2711 const int g = lv.g;
2712 const C3 lo{g + 1, g + 1, g + 1};
2713 const C3 hi{lv.ext.x - g - 1, lv.ext.y - g - 1, lv.ext.z - g - 1};
2714 const C3 rlo{g - 1, g - 1, g - 1};
2715 const C3 rhi{lv.ext.x - g + 1, lv.ext.y - g + 1, lv.ext.z - g + 1};
2716 lv.dev->exchange(lv.rhs); // ring rhs (owner's inner values); rhs is fixed over the sweeps
2717 for (int k = 0; k < sweeps; ++k) {
2718 const int c0 = reverse ? 1 : 0, c1 = 1 - c0;
2719 lv.dev->exchangeBegin(lv.x);
2720 cutcellSmoothColorBoxFace(lv.x, CCConst(lv.rhs), FPC(lv.AC), FPC(lv.AFX), FPC(lv.AFY),
2721 FPC(lv.AFZ), lv.ext, og, c0, lo, hi, C3{0, 0, 0}, C3{0, 0, 0});
2722 lv.dev->exchangeEnd(lv.x);
2723 cutcellSmoothColorBoxFace(lv.x, CCConst(lv.rhs), FPC(lv.AC), FPC(lv.AFX), FPC(lv.AFY),
2724 FPC(lv.AFZ), lv.ext, og, c0, rlo, rhi, lo, hi);
2725 cutcellSmoothColorFace(lv.x, CCConst(lv.rhs), FPC(lv.AC), FPC(lv.AFX), FPC(lv.AFY),
2726 FPC(lv.AFZ), lv.ext, og, g, c1);
2727 }
2728 return;
2729 }
2730#endif
2731 for (int k = 0; k < sweeps; ++k)
2732 for (int s = 0; s < 2; ++s) {
2733 const int color = reverse ? (1 - s) : s;
2734#ifdef PECLET_FLOW_MPI
2735 if (distributed_) {
2736 // Overlap the per-color halo with the interior sweep: post the exchange, smooth the
2737 // cells whose 7-point stencil reads no ghost (they depend on neither the incoming halo
2738 // nor the outflow ghost), complete the exchange, then sweep the boundary shell. A
2739 // color's cells never read same-color cells, so this ordering is bit-identical to the
2740 // blocking fill-then-full-sweep (validated by the np>1 bit-exact MG tests).
2741 const int g = lv.g;
2742 const C3 lo{g + 1, g + 1, g + 1};
2743 const C3 hi{lv.ext.x - g - 1, lv.ext.y - g - 1, lv.ext.z - g - 1};
2744 lv.dev->exchangeBegin(lv.x);
2745 cutcellSmoothColorBoxFace(lv.x, CCConst(lv.rhs), FPC(lv.AC), FPC(lv.AFX), FPC(lv.AFY),
2746 FPC(lv.AFZ), lv.ext, og, color, lo, hi, C3{0, 0, 0},
2747 C3{0, 0, 0});
2748 lv.dev->exchangeEnd(lv.x);
2749 applyOutflowGhost(lv, lv.x, g);
2750 cutcellSmoothColorBoxFace(lv.x, CCConst(lv.rhs), FPC(lv.AC), FPC(lv.AFX), FPC(lv.AFY),
2751 FPC(lv.AFZ), lv.ext, og, color, C3{g, g, g},
2752 C3{lv.ext.x - g, lv.ext.y - g, lv.ext.z - g}, lo, hi);
2753 continue;
2754 }
2755#endif
2756 if (fusedWrapSmooth(lv)) { // A3: no fill; the pass reads the periodic wrap directly
2757 cutcellSmoothColorFaceWrap(lv.x, CCConst(lv.rhs), FPC(lv.AC), FPC(lv.AFX), FPC(lv.AFY),
2758 FPC(lv.AFZ), lv.ext, lv.inner, og, lv.g, color);
2759 continue;
2760 }
2761 fill(lv, lv.x);
2762 applyOutflowGhost(lv, lv.x, lv.g);
2763 cutcellSmoothColorFace(lv.x, CCConst(lv.rhs), FPC(lv.AC), FPC(lv.AFX), FPC(lv.AFY),
2764 FPC(lv.AFZ), lv.ext, og, lv.g, color);
2765 }
2766 }
2767
2768 // --- when to agglomerate
2769 // ------------------------------------------------------------------------ A V-cycle only
2770 // converges at a rate independent of the domain if its COARSEST level is small enough to be
2771 // solved (essentially) exactly by the few smoother sweeps applied there. A geometric hierarchy
2772 // cannot always get there: an axis stops coarsening once it turns odd, and under MPI it stops
2773 // once any rank's block turns odd — so on a fixed per-rank block the coarsest GLOBAL grid grows
2774 // with the rank count and the bottom is progressively under-solved. That is the mechanism behind
2775 // weak-scaling curves that decay while communication stays negligible.
2776 //
2777 // Measured (single GPU, channel, Lx = 2048 x 64 x 64, everything else held): a smoothed bottom
2778 // needs 13.5 pressure iterations/step at 4 levels and 6.0 at 6 levels, against 4.4 at full
2779 // geometric depth. Agglomerating and solving that same bottom exactly gives 4.0 at BOTH 4 and 6
2780 // levels — depth-independent, and faster in wall-clock than the full-depth hierarchy (69.5 vs
2781 // 77.1 ms/step) because the extra levels cost more than the coarse solve they replace.
2782 //
2783 // NOT the default yet, and the reason is measured: on the cut-cell sphere-packing regression
2784 // (random_spheres, N=48) switching the bottom to the agglomerated solve makes the OUTER iteration
2785 // count WORSE (442 -> 622 total, +41 %) at unchanged accuracy, so the assembled coarse operator
2786 // is evidently not consistent with the V-cycle's on that IBM path. Until that is understood,
2787 // `auto` is opt-in and the legacy smoothed bottom stays the default. `mode`: 0 = never / plain
2788 // smoothed bottom (DEFAULT), -1 = auto, 1 = always. `setAgglomerationExtent` moves the
2789 // threshold; the ideal bottom is a handful of cells per axis.
2790 bool agglomerateBottom() const {
2791 if (agglomMode_ == 0)
2792 return false;
2793 if (agglomMode_ == 1)
2794 return true;
2795 if (lv_.empty())
2796 return false;
2797 // Auto engages only for the SINGULAR (periodic / all-Neumann / IBM) operator. On the
2798 // Dirichlet-anchored (outflow) path the exact bottom measurably LOWERS the outer solve's
2799 // attainable floor (128x32x32 inflow/outflow channel: flux divergence floor 8e-8 smoothed vs
2800 // 2e-5 agglomerated at identical budgets; the CSR solution satisfies the V-cycle's own bottom
2801 // operator to 1e-9, so this is not operator mismatch — the anchored operator's near-null mode
2802 // makes the exact bottom return O(1e3 |b|) corrections whose float-hierarchy round-off the
2803 // smoothed bottom never generates). Until that is understood, anchored operators keep the
2804 // smoothed bottom; set_pressure_bottom("agglomerated") still forces it anywhere.
2805 if (!removeMean_)
2806 return false;
2807 // The criterion is the coarsest grid's largest EXTENT, not its cell count: what a few smoother
2808 // sweeps cannot fix is a mode spanning many cells along an axis, and Gauss-Seidel needs O(L^2)
2809 // sweeps to damp a wavelength of L cells. A 64x2x2 bottom is only 256 cells yet still 64 across
2810 // -- measured, that costs 6.0 pressure iterations/step against 4.0 for an exact solve.
2811 const int thresh = agglomExtent_ > 0 ? agglomExtent_ : 4;
2812 // coarsest GLOBAL cell count (the local block does not decide how hard the coarse solve is)
2813 long gx = gnxF_, gy = gnyF_, gz = gnzF_;
2814 for (int L = 0; L + 1 < (int)lv_.size(); ++L) {
2815 gx /= lv_[L].ratio.x;
2816 gy /= lv_[L].ratio.y;
2817 gz /= lv_[L].ratio.z;
2818 }
2819 return gx > thresh || gy > thresh || gz > thresh;
2820 }
2821
2822 // --- The direct bottom solve (doc/vof_step_performance_design.md §5.7, §5.14, §13, §14 H-1) ---
2823 static constexpr bool kHostMemory =
2824 std::is_same_v<typename CCExec::memory_space, Kokkos::HostSpace>;
2825 // Eligibility (§13.4.6: §5.7 conditions 1-6 -- 6 = B1b's 1-64 fluid components -- and an axis
2826 // whose planes hold at most kBottomMaxPlane cells): nullptr when the device bottom may run, else
2827 // the first failed condition. Host-cheap: the components are labelled once per hierarchy
2828 // (evalBottomComponents, at the first setOpenness after init) and cached -- the rho / eps / drag
2829 // rescaling of the per-step setOpenness never closes a face, so it cannot change the answer.
2830 const char* directBottomIneligible() const {
2831 if (distributed_)
2832 return "the solve is distributed (multi-rank)";
2833 if (!removeMean_ || hasOutflow_)
2834 return "the operator is not the singular one (an outflow face is present)";
2835 if (!agglomerateBottom())
2836 return "the bottom is not agglomerated (set_pressure_bottom / set_pressure_bottom_extent)";
2837 if (lv_.empty() ||
2838 (long)lv_.back().inner.x * lv_.back().inner.y * lv_.back().inner.z > kBottomMaxCells)
2839 return "the bottom level has more than 8192 inner cells";
2840 if (!bottomConnKnown_)
2841 return "the pressure operator has not been built (no setOpenness since the hierarchy)";
2842 if (!bottomConnected_)
2843 return "the bottom level has no fluid cell or more than 64 fluid components (B1b)";
2844 if (!dirPlanes_.valid())
2845 return "no axis gives planes of at most 192 cells (the direct factor's cap)";
2846 return nullptr;
2847 }
2848 // The bottom's storage (§13.4.7): single rank (any backend since §14 H-1), a bottom of at most
2849 // kBottomMaxCells cells with ghost width 1; otherwise none and the engine is ineligible.
2850 // The FCG vectors, the component labels and their scratch, the plane ordering and the factor.
2852 facStale_ = true;
2853 bottomConnKnown_ = false;
2854 bottomConnected_ = false;
2855 bottomStore_ = false;
2856 dirPlanes_ = BottomPlanes{};
2857 if (distributed_ || lv_.empty())
2858 return;
2859 const Level& bt = lv_.back();
2860 if (bt.g != 1 || (long)bt.inner.x * bt.inner.y * bt.inner.z > kBottomMaxCells)
2861 return;
2862 bottomStore_ = true;
2863 for (CCField* f : {&bottomR_, &bottomP_, &bottomZ_, &bottomZp_, &bottomAp_})
2864 *f = CCField("mg_bottom_fcg", bt.n);
2865 bottomInfo_ = Kokkos::View<int*, CCMem>("peclet::flow::mg_bottom_info", 1);
2866 bottomComp_ = Kokkos::View<int*, CCMem>("peclet::flow::mg_bottom_comp", bt.n);
2867 bottomLabTmp_ = Kokkos::View<int*, CCMem>("peclet::flow::mg_bottom_labtmp", bt.n);
2868 bottomCompCnt_ =
2869 Kokkos::View<long*, CCMem>("peclet::flow::mg_bottom_compcnt", kBottomMaxComponents);
2870 bottomCompMean_ = CCField("peclet::flow::mg_bottom_compmean", kBottomMaxComponents);
2871 dirKc_ = Kokkos::View<int*, CCMem>("peclet::flow::mg_bottom_kc", kBottomMaxComponents);
2872 dirAug_ = Kokkos::View<double*, CCMem>("peclet::flow::mg_bottom_aug", kBottomMaxComponents);
2873 dirPlanes_ = bottomChoosePlanes(bt.inner, bc_);
2874 dir_ = BottomDirect<float>::allocate(dirPlanes_, bt.ext);
2875 dirTeam_ = 0;
2876 dirFacTeam_ = 0;
2877 }
2878 // Condition 6, as B1b replaced it (§5.14): the bottom level's fluid components, labelled on the
2879 // device by BottomLabelKernel -- eligible with 1..64 of them (the register's per-component
2880 // projector for the agglomerated bottom; more go to GraphAMG) -- and, along the slow axis, each
2881 // component's last plane and 1/m_c (§13.4.2). One team launch and one scalar read, at the first
2882 // operator build of a hierarchy (geometry time; the rho / eps / drag rescaling of the per-step
2883 // setOpenness never closes or opens a face, so the labels stay valid).
2885 bottomConnKnown_ = true;
2886 bottomConnected_ = false;
2887 bottomNComp_ = 0;
2888 if (!bottomStore_)
2889 return;
2890 const Level& bt = lv_.back();
2892 k.ext = bt.ext;
2893 k.inner = bt.inner;
2894 k.AC = bt.AC;
2895 k.AFX = bt.AFX;
2896 k.AFY = bt.AFY;
2897 k.AFZ = bt.AFZ;
2898 k.comp = bottomComp_;
2899 k.tmp = bottomLabTmp_;
2900 k.cnt = bottomCompCnt_;
2901 k.nc = bottomInfo_; // info(0) doubles as the component-count landing slot here
2902 k.slow = dirPlanes_.s;
2903 k.kc = dirKc_;
2904 k.aug = dirAug_;
2905 Kokkos::TeamPolicy<CCExec> probe(CCExec(), 1, 1);
2906 const int T = std::min(1024, probe.team_size_max(k, Kokkos::ParallelForTag()));
2907 Kokkos::parallel_for("peclet::flow::mg_bottom_label",
2908 Kokkos::TeamPolicy<CCExec>(CCExec(), 1, T), k);
2909 Kokkos::deep_copy(bottomNComp_, Kokkos::subview(bottomInfo_, 0));
2910 bottomConnected_ = bottomNComp_ >= 1 && bottomNComp_ <= kBottomMaxComponents;
2911 if (mgDebugLevel())
2912 printf("[mg] device bottom: %d fluid component(s) on the %dx%dx%d bottom%s\n", bottomNComp_,
2913 bt.inner.x, bt.inner.y, bt.inner.z,
2914 bottomConnected_ ? "" : " -> GraphAMG (more than 64, or none)");
2915 }
2916 template <class FR = float>
2918 ensureScalars();
2919 Level& bt = lv_.back();
2921 k.lv.ext = bt.ext;
2922 k.lv.inner = bt.inner;
2923 k.lv.AC = bt.AC;
2924 k.lv.AFX = bt.AFX;
2925 k.lv.AFY = bt.AFY;
2926 k.lv.AFZ = bt.AFZ;
2927 k.x = bt.x;
2928 k.b = bt.rhs;
2929 k.r = bottomR_;
2930 k.p = bottomP_;
2931 k.z = bottomZ_;
2932 k.zp = bottomZp_;
2933 k.ap = bottomAp_;
2934 k.precondOnly = precondOnly ? 1 : 0;
2935 k.ks = ks_;
2936 k.flagSlot = kBottomFlag;
2937 k.info = bottomInfo_;
2938 k.nc = bottomNComp_;
2939 k.comp = bottomComp_;
2940 k.ccnt = bottomCompCnt_;
2941 k.cmean = bottomCompMean_;
2942 return k;
2943 }
2944 // A non-finite scalar in a device bottom solve (the kernel's device flag) fails the solve. `v`
2945 // is the flag as read by the PCG packet or by lastSolveFailed(); a set flag is cleared on the
2946 // device so it is reported once.
2947 void noteBottomFlag(double v) {
2948 bottomFlagPending_ = false;
2949 if (v != 0.0) {
2950 solveFailed_ = true;
2951 setSlot(kBottomFlag, 0.0);
2952 }
2953 }
2954 // A launch's team size: min(1024, team_size_max) when team <= 0 -- min(hostTeam,
2955 // team_size_max) on a host backend (§14 H-1) -- else `team` clamped to team_size_max; with
2956 // `probe` false a positive `team` (a size this kernel already ran with) is used as is.
2957 // `scratch` = the team scratch (level 0) the kernel requests, in bytes.
2958 template <class K>
2959 static int directTeam(const K& k, int team, bool probe, std::size_t scratch = 0,
2960 int hostTeam = kBottomHostTeam) {
2961 if (team > 0 && !probe)
2962 return team;
2963 Kokkos::TeamPolicy<CCExec> pol(CCExec(), 1, 1);
2964 if (scratch)
2965 pol.set_scratch_size(0, Kokkos::PerTeam(scratch));
2966 const int tmax = std::min(1024, pol.team_size_max(k, Kokkos::ParallelForTag()));
2967 if (team > 0)
2968 return std::min(team, tmax);
2969 return kHostMemory ? std::min(hostTeam, tmax) : tmax;
2970 }
2971 // The factor launch: one team, T = directTeam(team) (the U3 hook passes T; the factor is bitwise
2972 // independent of it). `teamAlgorithm` (a test hook, U8) runs the team algorithm on a host
2973 // backend instead of the host schedule. Returns the team size used.
2974 template <class FR>
2975 int launchDirectFactor(const BottomDirect<FR>& D, int team, double tauPiv0, bool probe = true,
2976 bool teamAlgorithm = false) {
2977 const Level& bt = lv_.back();
2979 k.D = D;
2980 k.AFX = bt.AFX;
2981 k.AFY = bt.AFY;
2982 k.AFZ = bt.AFZ;
2983 k.comp = bottomComp_;
2984 k.kc = dirKc_;
2985 k.aug = dirAug_;
2986 k.tauPiv0 = tauPiv0;
2987 k.teamAlgorithm = teamAlgorithm ? 1 : 0;
2988 const std::size_t scratch = BottomFactorKernel<FR, FPC>::scratchBytes(D.pl.b);
2989 const int T = directTeam(k, team, probe, scratch, kBottomHostFactorTeam);
2990 Kokkos::parallel_for(
2991 "peclet::flow::mg_bottom_factor",
2992 Kokkos::TeamPolicy<CCExec>(CCExec(), 1, T).set_scratch_size(0, Kokkos::PerTeam(scratch)),
2993 k);
2994 return T;
2995 }
2996 // The FCG kernel with M = the direct factor `D`: the production solve (precondOnly = false) or,
2997 // for the tests, z = M(lv.rhs) alone. Returns the team size used.
2998 template <class FR>
2999 int launchDirectKernel(const BottomDirect<FR>& D, bool precondOnly, int team, bool noMean,
3000 CCField zOut = CCField(), CCField rIn = CCField(), bool probe = true) {
3001 BottomKernel<FR> k = makeBottomKernel<FR>(precondOnly);
3002 k.dir = D;
3003 k.noMeanForTest = noMean ? 1 : 0;
3004 if (precondOnly) {
3005 k.z = zOut;
3006 k.b = rIn;
3007 }
3008 const int T = directTeam(k, team, probe);
3009 Kokkos::parallel_for("peclet::flow::mg_bottom_direct",
3010 Kokkos::TeamPolicy<CCExec>(CCExec(), 1, T), k);
3011 return T;
3012 }
3013 // The production direct bottom solve: lv.x = A_b^{-1} lv.rhs (component-mean-free) by the FCG
3014 // preconditioned by the FP32 factor, refactored first if setOpenness changed the operator (a
3015 // host flag, not a device read).
3017 const bool first = dirTeam_ == 0;
3018 if (facStale_) {
3019 dirFacTeam_ = launchDirectFactor(dir_, dirFacTeam_, dirPivotTol_, /*probe=*/false);
3020 facStale_ = false;
3021 if (mgDebugLevel() >= 2 && dbgSolve_ <= mgDebugSolves()) {
3022 auto h = Kokkos::create_mirror_view_and_copy(Kokkos::HostSpace(), dir_.stat);
3023 printf("[mg] direct bottom factor: ok %d, restarts %d (team %d)\n", h(0), h(1),
3024 dirFacTeam_);
3025 }
3026 }
3027 dirTeam_ =
3028 launchDirectKernel(dir_, false, dirTeam_, false, CCField(), CCField(), /*probe=*/false);
3029 if (first && mgDebugLevel()) {
3030 printf(
3031 "[mg] direct bottom: %dx%dx%d, slow axis %d, P = %d, b = %d, border %d; team sizes "
3032 "factor %d, solve %d\n",
3033 lv_.back().inner.x, lv_.back().inner.y, lv_.back().inner.z, dirPlanes_.s, dirPlanes_.P,
3034 dirPlanes_.b, dirPlanes_.border, dirFacTeam_, dirTeam_);
3035 fflush(stdout);
3036 }
3037 bottomFlagPending_ = true;
3038 if (mgDebugLevel() >= 2 && dbgSolve_ <= mgDebugSolves()) {
3039 int it = 0;
3040 Kokkos::deep_copy(it, Kokkos::subview(bottomInfo_, 0));
3041 printf("[mg] direct bottom: %d inner iterations (team %d)\n", it, dirTeam_);
3042 }
3043 }
3044
3045 // --- Agglomerated GraphAMG bottom solve --------------------------------------------------------
3046 // Assemble the coarsest level's cut-cell operator as a GLOBAL CSR (gathered to rank 0) and build
3047 // a mesh-agnostic smoothed-aggregation AMG on it. Decomposition-agnostic: the CSR is keyed by
3048 // GLOBAL cell id (periodic-wrapped neighbours), so any (weighted) ORB gives the SAME operator.
3049 void buildAmg(Level& lv) {
3050 int gbx = gnxF_, gby = gnyF_,
3051 gbz = gnzF_; // bottom global dims (coarsen by the ratios above it)
3052 for (int L = 0; L + 1 < (int)lv_.size(); ++L) {
3053 gbx /= lv_[L].ratio.x;
3054 gby /= lv_[L].ratio.y;
3055 gbz /= lv_[L].ratio.z;
3056 }
3057 amgGlobalN_ = gbx * gby * gbz;
3058 const int nx = lv.inner.x, ny = lv.inner.y, nz = lv.inner.z, ex = lv.ext.x, ey = lv.ext.y;
3059 auto host = [](FPV v) {
3060 auto h = Kokkos::create_mirror_view(v);
3061 Kokkos::deep_copy(h, v);
3062 return h;
3063 };
3064 auto hC = host(lv.AC), hAFX = host(lv.AFX), hAFY = host(lv.AFY), hAFZ = host(lv.AFZ);
3065 // this rank's rows: (gid, diag) and off-diagonals (gid -> ngid, coef), periodic-wrapped.
3066 std::vector<int> lgid, lrow, lcol;
3067 std::vector<double> ldiag, lval;
3068 std::vector<std::uint8_t> lsolid; // AGMG_DEBUG: identity-row marker, per local row
3069 amgGlobalOfLocal_.clear();
3070 const int band[6][3] = {{-1, 0, 0}, {1, 0, 0}, {0, -1, 0}, {0, 1, 0}, {0, 0, -1}, {0, 0, 1}};
3071 const int g = lv.g;
3072 for (int k = 0; k < nz; ++k)
3073 for (int j = 0; j < ny; ++j)
3074 for (int i = 0; i < nx; ++i) {
3075 const long p = (long)(i + g) + (long)(j + g) * ex + (long)(k + g) * ex * ey;
3076 const int gx = lv.og.x + i, gy = lv.og.y + j, gz = lv.og.z + k;
3077 const int gid = gx + gy * gbx + gz * gbx * gby;
3078 amgGlobalOfLocal_.push_back(gid);
3079 lgid.push_back(gid);
3080 // solid cells (all faces closed => diag 0, no coupling) get an identity row so D^-1 is
3081 // finite; their rhs is 0, so x stays 0 (correct — no flow inside the solid).
3082 const double dc = (double)hC(p);
3083 ldiag.push_back(dc != 0.0 ? dc : 1.0);
3084 lsolid.push_back(dc == 0.0 ? 1 : 0);
3085 // A2 face form: AW = AFX(p), AE = AFX(p+1), ... (the band values themselves).
3086 const double bc[6] = {(double)hAFX(p), (double)hAFX(p + 1),
3087 (double)hAFY(p), (double)hAFY(p + ex),
3088 (double)hAFZ(p), (double)hAFZ(p + (long)ex * ey)};
3089 for (int d = 0; d < 6; ++d) {
3090 if (bc[d] == 0.0)
3091 continue; // closed face (wall) -> no coupling
3092 // A face crossing the domain boundary couples to the wrapped cell ONLY on a periodic
3093 // axis (bc_ type 0). On a non-periodic axis an OPEN boundary face is the Dirichlet
3094 // outflow anchor: its coefficient lives in the diagonal (already in AC) with NO
3095 // off-diagonal partner — wrapping it would add a spurious top<->bottom coupling and
3096 // (with the mean projection skipped) a wrong, possibly indefinite bottom matrix.
3097 const int rx = gx + band[d][0], ry = gy + band[d][1], rz = gz + band[d][2];
3098 const int axis = d / 2;
3099 const bool crosses = (axis == 0 && (rx < 0 || rx >= gbx)) ||
3100 (axis == 1 && (ry < 0 || ry >= gby)) ||
3101 (axis == 2 && (rz < 0 || rz >= gbz));
3102 if (crosses && bc_[d] != 0)
3103 continue; // non-periodic boundary face: Dirichlet anchor stays diagonal-only
3104 const int ngx = (rx % gbx + gbx) % gbx;
3105 const int ngy = (ry % gby + gby) % gby;
3106 const int ngz = (rz % gbz + gbz) % gbz;
3107 lrow.push_back(gid);
3108 lcol.push_back(ngx + ngy * gbx + ngz * gbx * gby);
3109 lval.push_back(bc[d]);
3110 }
3111 }
3112 // all-gather every rank's rows (no-op / identity single-rank): EVERY rank assembles the same
3113 // global CSR and builds the same AMG (redundant coarse solve).
3114 std::vector<int> ggid = lgid, grow = lrow, gcol = lcol;
3115 std::vector<double> gdiag = ldiag, gval = lval;
3116 std::vector<std::uint8_t> gsolid = lsolid;
3117#ifdef PECLET_FLOW_MPI
3118 if (distributed_) {
3119 gatherv(lgid, ggid, lv.comm);
3120 gatherv(ldiag, gdiag, lv.comm);
3121 gatherv(lrow, grow, lv.comm);
3122 gatherv(lcol, gcol, lv.comm);
3123 gatherv(lval, gval, lv.comm);
3124 gatherv(lsolid, gsolid, lv.comm);
3125 }
3126#endif
3127 amgSolid_.assign((std::size_t)amgGlobalN_, 0);
3128 for (std::size_t r = 0; r < ggid.size(); ++r)
3129 amgSolid_[(std::size_t)ggid[r]] = gsolid[r];
3130 { // connected components of the operator graph (union-find over the off-diagonal edges):
3131 // each FLUID component carries its own constant null vector, so the null-space projection
3132 // must be per-component. Solid identity rows are singletons and take no projection.
3133 std::vector<int> parent((std::size_t)amgGlobalN_);
3134 for (int i = 0; i < amgGlobalN_; ++i)
3135 parent[(std::size_t)i] = i;
3136 auto find = [&](int a) {
3137 while (parent[(std::size_t)a] != a)
3138 a = parent[(std::size_t)a] = parent[(std::size_t)parent[(std::size_t)a]];
3139 return a;
3140 };
3141 for (std::size_t e = 0; e < grow.size(); ++e) {
3142 const int ra = find(grow[e]), rb = find(gcol[e]);
3143 if (ra != rb)
3144 parent[(std::size_t)ra] = rb;
3145 }
3146 amgComp_.assign((std::size_t)amgGlobalN_, -1);
3147 std::vector<int> remap((std::size_t)amgGlobalN_, -1);
3148 amgNComp_ = 0;
3149 for (int i = 0; i < amgGlobalN_; ++i) {
3150 if (amgSolid_[(std::size_t)i])
3151 continue; // identity row: no null space, excluded from projection
3152 const int r = find(i);
3153 if (remap[(std::size_t)r] < 0)
3154 remap[(std::size_t)r] = amgNComp_++;
3155 amgComp_[(std::size_t)i] = remap[(std::size_t)r];
3156 }
3157 }
3158 if (agmgDebug()) {
3159 std::vector<long> csize((std::size_t)amgNComp_, 0);
3160 for (int i = 0; i < amgGlobalN_; ++i)
3161 if (amgComp_[(std::size_t)i] >= 0)
3162 ++csize[(std::size_t)amgComp_[(std::size_t)i]];
3163 printf("[agmg-build] fluid components=%d sizes:", amgNComp_);
3164 for (int c = 0; c < std::min(amgNComp_, 12); ++c)
3165 printf(" %ld", csize[(std::size_t)c]);
3166 printf(amgNComp_ > 12 ? " ...\n" : "\n");
3167 }
3168 if (agmgDebug()) {
3169 long nSolid = 0, nTiny30 = 0, nTiny12 = 0;
3170 double minFluidDiag = 1e300, maxFluidDiag = 0;
3171 for (std::size_t r = 0; r < ggid.size(); ++r) {
3172 if (gsolid[r]) {
3173 ++nSolid;
3174 continue;
3175 }
3176 const double ad = std::fabs(gdiag[r]);
3177 minFluidDiag = std::min(minFluidDiag, ad);
3178 maxFluidDiag = std::max(maxFluidDiag, ad);
3179 nTiny30 += ad < 1e-30;
3180 nTiny12 += ad < 1e-12;
3181 }
3182 printf(
3183 "[agmg-build] n=%d solid=%ld fluid=%ld fluid|diag| min=%.3e max=%.3e "
3184 "tiny<1e-30=%ld <1e-12=%ld\n",
3185 amgGlobalN_, nSolid, (long)ggid.size() - nSolid, minFluidDiag, maxFluidDiag, nTiny30,
3186 nTiny12);
3187 // Row-sum defect: the operator's null vector is the constant ONLY if every fluid row sums
3188 // to zero. The level coefficients are stored in float (MReal), so the diagonal is a
3189 // float-rounded sum of the face coefficients — a nonzero defect here bounds how far a
3190 // singular-consistent solve can converge.
3191 std::vector<double> rowsum((std::size_t)amgGlobalN_, 0.0);
3192 for (std::size_t r = 0; r < ggid.size(); ++r)
3193 rowsum[(std::size_t)ggid[r]] = gsolid[r] ? 0.0 : gdiag[r];
3194 for (std::size_t e = 0; e < grow.size(); ++e)
3195 if (!amgSolid_[(std::size_t)grow[e]])
3196 rowsum[(std::size_t)grow[e]] += gval[e];
3197 double defMax = 0, defRelMax = 0;
3198 for (std::size_t r = 0; r < ggid.size(); ++r)
3199 if (!gsolid[r]) {
3200 const double d = std::fabs(rowsum[(std::size_t)ggid[r]]);
3201 defMax = std::max(defMax, d);
3202 defRelMax = std::max(defRelMax, d / std::fabs(gdiag[r]));
3203 }
3204 printf("[agmg-build] fluid row-sum defect max=%.3e rel=%.3e\n", defMax, defRelMax);
3205 fflush(stdout);
3206 }
3207 { // assemble the global CSR keyed by gid
3208 peclet::core::solver::HostCsrOp A;
3209 A.n = amgGlobalN_;
3210 A.diag.assign((std::size_t)amgGlobalN_, 0.0);
3211 for (std::size_t r = 0; r < ggid.size(); ++r)
3212 A.diag[(std::size_t)ggid[r]] = gdiag[r];
3213 std::vector<std::vector<std::pair<int, double>>> rows((std::size_t)amgGlobalN_);
3214 for (std::size_t e = 0; e < grow.size(); ++e)
3215 rows[(std::size_t)grow[e]].push_back({gcol[e], gval[e]});
3216 A.start.assign((std::size_t)amgGlobalN_ + 1, 0);
3217 for (int r = 0; r < amgGlobalN_; ++r)
3218 A.start[(std::size_t)r + 1] = A.start[(std::size_t)r] + (long)rows[(std::size_t)r].size();
3219 A.nbr.reserve(grow.size());
3220 A.coef.reserve(grow.size());
3221 for (int r = 0; r < amgGlobalN_; ++r)
3222 for (auto& [c, v] : rows[(std::size_t)r]) {
3223 A.nbr.push_back(c);
3224 A.coef.push_back(v);
3225 }
3226 // Singular (periodic / all-Neumann) path: every fluid diagonal is BY CONSTRUCTION the
3227 // negative sum of its off-diagonals (walls/solids contribute zero, and no Dirichlet anchor
3228 // exists when removeMean_). The float (MReal) level storage breaks that identity at ~5e-8
3229 // relative, which shifts the operator's near-null vector off the constant the null-space
3230 // projection assumes — measured as the inner CG flooring at ~1e-5 and burning its full
3231 // iteration cap every call. Resum the diagonal in double so A·1 = 0 EXACTLY per fluid row.
3232 if (removeMean_)
3233 for (int r = 0; r < amgGlobalN_; ++r)
3234 if (!amgSolid_[(std::size_t)r] && !rows[(std::size_t)r].empty()) {
3235 double s = 0;
3236 for (auto& [c, v] : rows[(std::size_t)r])
3237 s += v;
3238 A.diag[(std::size_t)r] = -s;
3239 }
3240 amgA_ = A;
3241 amg_ = std::make_shared<peclet::core::solver::GraphAMG>();
3242 amg_->build(A);
3243 }
3244 }
3245 // Solve the coarsest level with the agglomerated AMG, REDUNDANTLY: all-gather the coarse rhs,
3246 // every rank runs the identical GraphAMG-preconditioned CG on the identical global operator
3247 // (deterministic serial code on identical data => bit-identical solutions), and each extracts
3248 // its own block — one Allgatherv per V-cycle, no rank-0 serialization, no result broadcast.
3250 if (!amg_ && !distributed_)
3251 buildAmg(lv);
3252#ifdef PECLET_FLOW_MPI
3253 if (distributed_ && amgGlobalN_ == 0)
3254 buildAmg(lv);
3255#endif
3256 const int nx = lv.inner.x, ny = lv.inner.y, nz = lv.inner.z, ex = lv.ext.x, ey = lv.ext.y;
3257 const int g = lv.g;
3258 auto hrhs = Kokkos::create_mirror_view(lv.rhs);
3259 Kokkos::deep_copy(hrhs, lv.rhs);
3260 std::vector<double> lb;
3261 lb.reserve(amgGlobalOfLocal_.size());
3262 for (int k = 0; k < nz; ++k)
3263 for (int j = 0; j < ny; ++j)
3264 for (int i = 0; i < nx; ++i)
3265 lb.push_back((double)hrhs((long)(i + g) + (long)(j + g) * ex + (long)(k + g) * ex * ey));
3266 // all-gather rhs by global id -> b; every rank solves the identical problem.
3267 std::vector<double> z((std::size_t)std::max(amgGlobalN_, 1), 0.0);
3268#ifdef PECLET_FLOW_MPI
3269 if (distributed_) {
3270 std::vector<int> ggid;
3271 std::vector<double> gb;
3272 gatherv(amgGlobalOfLocal_, ggid, lv.comm);
3273 gatherv(lb, gb, lv.comm);
3274 std::vector<double> b((std::size_t)amgGlobalN_, 0.0);
3275 for (std::size_t r = 0; r < ggid.size(); ++r)
3276 b[(std::size_t)ggid[r]] = gb[r];
3277 pcgAmg(b, z);
3278 } else
3279#endif
3280 {
3281 std::vector<double> b(lb.begin(), lb.end());
3282 pcgAmg(b, z);
3283 }
3284 // scatter z[gid] back into this rank's inner cells.
3285 auto hx = Kokkos::create_mirror_view(lv.x);
3286 Kokkos::deep_copy(hx, 0.0);
3287 std::size_t c = 0;
3288 for (int k = 0; k < nz; ++k)
3289 for (int j = 0; j < ny; ++j)
3290 for (int i = 0; i < nx; ++i)
3291 hx((long)(i + g) + (long)(j + g) * ex + (long)(k + g) * ex * ey) =
3292 z[(std::size_t)amgGlobalOfLocal_[c++]];
3293 Kokkos::deep_copy(lv.x, hx);
3294 if (agmgDebug() && !distributed_) {
3295 // Consistency check: the CSR solution must satisfy the V-cycle's OWN bottom operator
3296 // (ghost fill + outflow ghost + 7-point apply). A large residual here means buildAmg
3297 // assembled a DIFFERENT matrix than the one the hierarchy applies.
3298 fill(lv, lv.x);
3299 applyOutflowGhost(lv, lv.x, lv.g);
3300 residualCutcellFace(lv.res, CCConst(lv.x), CCConst(lv.rhs), FPC(lv.AC), FPC(lv.AFX),
3301 FPC(lv.AFY), FPC(lv.AFZ), lv.ext, lv.g);
3302 const double rn = maxabs(lv, lv.res);
3303 auto hb = Kokkos::create_mirror_view(lv.rhs);
3304 Kokkos::deep_copy(hb, lv.rhs);
3305 double bn = 0;
3306 for (std::size_t i = 0; i < hb.size(); ++i)
3307 bn = std::max(bn, std::fabs((double)hb(i)));
3308 printf("[agmg] vcycle-op residual of CSR solution: max|b-Ax|=%.3e max|b|=%.3e rel=%.3e\n",
3309 rn, bn, bn > 0 ? rn / bn : 0.0);
3310 fflush(stdout);
3311 }
3312 }
3313 // GraphAMG-preconditioned CG on the global bottom operator. For the periodic/all-Neumann case the
3314 // operator is singular (constant null space) and the mean must be projected out of the rhs and
3315 // the preconditioned residual (compatibility). With a Dirichlet outflow (removeMean_ == false)
3316 // the operator is NON-singular and the projection must be SKIPPED — removing the constant from a
3317 // non-singular system returns a wrong bottom correction and the V-cycle around it diverges.
3318 // Runs on rank 0 only.
3319 void pcgAmg(std::vector<double>& b, std::vector<double>& x) {
3320 const std::size_t n = (std::size_t)amgGlobalN_;
3321 const int dbg = agmgDebug();
3322 double bSolidMax = 0, bFluidMax = 0, bFluidMean = 0, bAllMean = 0;
3323 if (dbg) { // rhs anatomy BEFORE the null-space projection (b is gid-ordered)
3324 long nf = 0;
3325 double sf = 0, sa = 0;
3326 for (std::size_t i = 0; i < n; ++i) {
3327 sa += b[i];
3328 if (i < amgSolid_.size() && amgSolid_[i])
3329 bSolidMax = std::max(bSolidMax, std::fabs(b[i]));
3330 else {
3331 bFluidMax = std::max(bFluidMax, std::fabs(b[i]));
3332 sf += b[i];
3333 ++nf;
3334 }
3335 }
3336 bFluidMean = nf ? sf / (double)nf : 0.0;
3337 bAllMean = n ? sa / (double)n : 0.0;
3338 }
3339 double bCompMax = 0; // max per-component |sum(b)| / |b|_max: the per-pocket incompatibility
3340 if (dbg && amgNComp_ > 1) {
3341 std::vector<double> cs((std::size_t)amgNComp_, 0.0);
3342 for (std::size_t i = 0; i < n; ++i)
3343 if (amgComp_[i] >= 0)
3344 cs[(std::size_t)amgComp_[i]] += b[i];
3345 for (double s : cs)
3346 bCompMax = std::max(bCompMax, std::fabs(s));
3347 bCompMax /= (bFluidMax > 0 ? bFluidMax : 1.0);
3348 }
3349 auto meanZero = [&](std::vector<double>& v) {
3350 if (!removeMean_)
3351 return; // Dirichlet-anchored (outflow) operator: non-singular, no null space to project
3352 // The null space is one constant PER CONNECTED FLUID COMPONENT — solid cells are identity
3353 // rows (non-singular) and a coarse level can pinch fluid off into pockets, each carrying its
3354 // own constant. Projecting the ALL-cell mean out instead (the old code) both leaves null
3355 // components alive and writes a spurious value onto every solid coordinate; the next matvec
3356 // (identity rows) feeds that back into the residual, the effective preconditioner turns
3357 // nonsymmetric, and the inner CG stalls at its iteration cap (measured on random_spheres:
3358 // every bottom solve capped at 200 with relres up to ~1, +41% outer iterations).
3359 if (amgNComp_ <= 0)
3360 return;
3361 std::vector<double> m((std::size_t)amgNComp_, 0.0);
3362 std::vector<long> cnt((std::size_t)amgNComp_, 0);
3363 for (std::size_t i = 0; i < n; ++i)
3364 if (amgComp_[i] >= 0) {
3365 m[(std::size_t)amgComp_[i]] += v[i];
3366 ++cnt[(std::size_t)amgComp_[i]];
3367 }
3368 for (std::size_t c = 0; c < m.size(); ++c)
3369 m[c] = cnt[c] ? m[c] / (double)cnt[c] : 0.0;
3370 for (std::size_t i = 0; i < n; ++i)
3371 if (amgComp_[i] >= 0)
3372 v[i] -= m[(std::size_t)amgComp_[i]];
3373 };
3374 meanZero(b);
3375 x.assign(n, 0.0);
3376 std::vector<double> r = b, z(n), p(n), Ap(n);
3377 amg_->apply(r, z);
3378 meanZero(z);
3379 p = z;
3380 auto dot = [&](const std::vector<double>& a, const std::vector<double>& c) {
3381 double s = 0;
3382 for (std::size_t i = 0; i < n; ++i)
3383 s += a[i] * c[i];
3384 return s;
3385 };
3386 double rz = dot(r, z), r0 = std::sqrt(dot(r, r));
3387 // 1e-8 is deliberate: the V-cycle's outer iteration count is unchanged from a far looser
3388 // bottom (measured), while 1e-10 sits at/below the double-precision floor of this
3389 // projected solve (rounding of the per-iteration null-space projection), where CG grinds
3390 // out its full iteration cap for nothing.
3391 int it = 0;
3392 for (; it < 100 && r0 > 0; ++it) {
3393 amgA_.apply(p, Ap);
3394 // CG breakdown guard (core's GraphAMG pcg has the same one): p^T A p = 0 means the search
3395 // direction lies in the null space, i.e. the projected rhs has no range component left and
3396 // x is already the answer. It happens when the bottom rhs is round-off on a constant: the
3397 // projection's rounded mean differs from the value by an ulp and leaves an exactly CONSTANT
3398 // residual (8 x -0x1.ffffffffffffcp-103 -> 8 x 0x1p-154 on the uniform periodic porous bed),
3399 // the AMG maps it to z = 0, and rz / pAp = 0/0 poisoned x with NaN, which the outer PCG then
3400 // reported as a non-finite preconditioner and a CAPPED solve.
3401 const double pAp = dot(p, Ap);
3402 if (!(pAp > 0.0))
3403 break;
3404 const double a = rz / pAp;
3405 for (std::size_t i = 0; i < n; ++i) {
3406 x[i] += a * p[i];
3407 r[i] -= a * Ap[i];
3408 }
3409 if (std::sqrt(dot(r, r)) <= 1e-8 * r0)
3410 break;
3411 amg_->apply(r, z);
3412 meanZero(z);
3413 const double rzn = dot(r, z);
3414 const double beta = rzn / rz;
3415 rz = rzn;
3416 for (std::size_t i = 0; i < n; ++i)
3417 p[i] = z[i] + beta * p[i];
3418 }
3419 meanZero(x);
3420 if (dbg) {
3421 double xSolidMax = 0, xFluidMax = 0;
3422 for (std::size_t i = 0; i < n; ++i)
3423 if (i < amgSolid_.size() && amgSolid_[i])
3424 xSolidMax = std::max(xSolidMax, std::fabs(x[i]));
3425 else
3426 xFluidMax = std::max(xFluidMax, std::fabs(x[i]));
3427 const double rn = std::sqrt(dot(r, r));
3428 ++agmgCalls_;
3429 if (dbg >= 2 || agmgCalls_ <= 60 || it >= 100 || agmgCalls_ % 50 == 0) {
3430 printf(
3431 "[agmg] call=%ld iters=%d relres=%.2e |b|sol=%.3e |b|fl=%.3e "
3432 "mean(b) fl=%.3e all=%.3e compat=%.2e |x|sol=%.3e |x|fl=%.3e%s\n",
3433 agmgCalls_, it, r0 > 0 ? rn / r0 : 0.0, bSolidMax, bFluidMax, bFluidMean, bAllMean,
3434 bCompMax, xSolidMax, xFluidMax, it >= 100 ? " CAP" : "");
3435 fflush(stdout);
3436 }
3437 }
3438 }
3439#ifdef PECLET_FLOW_MPI
3440 template <class T>
3441 void gatherv(const std::vector<T>& local, std::vector<T>& all, MPI_Comm c) {
3442 // REDUNDANT agglomeration: every rank receives the full concatenation (rank order, so the
3443 // assembled coarse problem is bit-identical on all ranks and each solves it locally with no
3444 // rank-0 bottleneck and no result broadcast).
3445 int size = 1;
3446 MPI_Comm_size(c, &size);
3447 const int lbytes = (int)(local.size() * sizeof(T));
3448 std::vector<int> bc(size), bd(size, 0);
3449 MPI_Allgather(&lbytes, 1, MPI_INT, bc.data(), 1, MPI_INT, c);
3450 int tot = 0;
3451 for (int r = 0; r < size; ++r) {
3452 bd[r] = tot;
3453 tot += bc[r];
3454 }
3455 all.resize((std::size_t)tot / sizeof(T));
3456 MPI_Allgatherv(local.data(), lbytes, MPI_BYTE, all.data(), bc.data(), bd.data(), MPI_BYTE, c);
3457 }
3458#endif
3459
3460 // Level-0 matvec with the halo overlapped: post the exchange, apply the interior rows while the
3461 // messages are in flight, land the ghosts (+ outflow ghost), apply the boundary shell. Reads v,
3462 // writes y (no aliasing) => bit-identical to the blocking fill-then-apply. Single-rank: the
3463 // blocking path.
3465 const bool ex = exactResidual_; // P1: exact double flux-form apply instead of the bands
3466#ifdef PECLET_FLOW_MPI
3467 if (distributed_) {
3468 const C3 lo{G + 1, G + 1, G + 1};
3469 const C3 hi{l0.ext.x - G - 1, l0.ext.y - G - 1, l0.ext.z - G - 1};
3470 l0.dev->exchangeBegin(v);
3471 if (ex)
3473 l0.ext, lo, hi, C3{0, 0, 0}, C3{0, 0, 0}, gfx_, gfy_, gfz_);
3474 else
3475 applyCutcellOpBoxFace(y, CCConst(v), FPC(l0.AC), FPC(l0.AFX), FPC(l0.AFY), FPC(l0.AFZ),
3476 l0.ext, lo, hi, C3{0, 0, 0}, C3{0, 0, 0});
3477 l0.dev->exchangeEnd(v);
3479 if (ex)
3481 l0.ext, C3{G, G, G}, C3{l0.ext.x - G, l0.ext.y - G, l0.ext.z - G},
3482 lo, hi, gfx_, gfy_, gfz_);
3483 else
3484 applyCutcellOpBoxFace(y, CCConst(v), FPC(l0.AC), FPC(l0.AFX), FPC(l0.AFY), FPC(l0.AFZ),
3485 l0.ext, C3{G, G, G}, C3{l0.ext.x - G, l0.ext.y - G, l0.ext.z - G}, lo,
3486 hi);
3487 return;
3488 }
3489#endif
3490 if (fusedWrapReads()) { // A3: the matvec reads the periodic wrap directly, no fill
3491 if (ex)
3493 l0.ext, l0.inner, G, gfx_, gfy_, gfz_);
3494 else
3495 applyCutcellOpFaceWrap(y, CCConst(v), FPC(l0.AC), FPC(l0.AFX), FPC(l0.AFY), FPC(l0.AFZ),
3496 l0.ext, l0.inner, G);
3497 return;
3498 }
3499 fill(l0, v);
3501 if (ex)
3502 applyCutcellOpExact(y, CCConst(v), CCConst(l0.ox), CCConst(l0.oy), CCConst(l0.oz), l0.ext, G,
3503 gfx_, gfy_, gfz_);
3504 else
3505 applyCutcellOpFace(y, CCConst(v), FPC(l0.AC), FPC(l0.AFX), FPC(l0.AFY), FPC(l0.AFZ), l0.ext,
3506 G);
3507 }
3508 // periodic ghost fill (3 axes) of a level-sized field / the openness triple. Distributed: the
3509 // per-level core halo (cross-rank + periodic in one call).
3510 void fill(Level& lv, CCField f) {
3511#ifdef PECLET_FLOW_MPI
3512 if (distributed_) {
3513 lv.dev->exchange(f);
3514 return;
3515 }
3516#endif
3517 if (lv.inner.x < lv.g || lv.inner.y < lv.g || lv.inner.z < lv.g) {
3518 // a ghost band wider than the block: the sequential passes copy ghost from ghost, which the
3519 // one-shot wrap does not reproduce -- keep them
3520 fillAxis(lv, f, 0);
3521 fillAxis(lv, f, 1);
3522 fillAxis(lv, f, 2);
3523 return;
3524 }
3525 fillWrap(lv, f);
3526 }
3527 // The single-rank periodic fill in ONE launch over the ghost shell: every ghost cell takes the
3528 // value of the inner cell its three coordinates wrap to. That is exactly what the three
3529 // sequential `fillAxis` passes (x, then y, then z, each over the full transverse extent, ghosts
3530 // included) leave behind -- a ghost already written by an earlier pass is overwritten by the
3531 // later pass with the wrapped-inner value, and inner cells are only ever read -- so the two are
3532 // bit-identical (pure copies). The V-cycle calls this before every smoother colour, so the three
3533 // launches it replaces were two thirds of the solve's kernel launches, and on the coarse levels
3534 // the launch, not the copy, is the cost.
3535 void fillWrap(Level& lv, CCField f) {
3536 CCExec space;
3537 const C3 e = lv.ext;
3538 const int G = lv.g; // shadows the class constant: this level's ghost width
3539 const int nx = lv.inner.x, ny = lv.inner.y, nz = lv.inner.z;
3540 const long exy = (long)e.x * e.y;
3541 // the shell as three slabs: z-ghost planes (all x, y), then y-ghost rows of the inner z range
3542 // (all x), then x-ghost cells of the inner (y, z) range
3543 const long nZ = 2L * G * exy, nY = (long)nz * 2 * G * e.x, nX = (long)nz * ny * 2 * G;
3544 if constexpr (std::is_same_v<typename CCExec::memory_space, Kokkos::HostSpace>) {
3545 // A3 (§4.3, §5.4): on a host backend the same three slabs go ROW by row -- the index math
3546 // (one division) per row, a contiguous copy along x inside it -- serially below the cutoff.
3547 // The one-thread-per-cell form below paid a 64-bit div/mod per ghost cell (85 us per
3548 // launch at 24 threads). Every ghost cell is still written once, from the inner cell its
3549 // three coordinates wrap to, so the result is identical. No simd mark: a row's source and
3550 // destination can share the row (the x-ghost slab).
3551 const long rZ = 2L * G * e.y, rY = (long)nz * 2 * G, rX = (long)nz * ny;
3552 CCField ff = f;
3553 auto copyRow = [=](long dst, long src) { // one full x row, wrapped along x
3554 for (int x = 0; x < G; ++x)
3555 ff(dst + x) = ff(src + x + nx);
3556 for (int x = G; x < G + nx; ++x)
3557 ff(dst + x) = ff(src + x);
3558 for (int x = G + nx; x < e.x; ++x)
3559 ff(dst + x) = ff(src + x - nx);
3560 };
3561 auto row = [=](long t) {
3562 if (t < rZ) { // z-ghost plane row (y, zg)
3563 const int l = (int)(t / e.y), y = (int)(t - (long)l * e.y);
3564 const int z = l < G ? l : nz + l;
3565 const int sy = y < G ? y + ny : (y >= G + ny ? y - ny : y);
3566 const int sz = z < G ? z + nz : z - nz;
3567 copyRow((long)y * e.x + (long)z * exy, (long)sy * e.x + (long)sz * exy);
3568 } else if (t < rZ + rY) { // y-ghost row (yg, z) of the inner z range
3569 const long t2 = t - rZ;
3570 const int zi = (int)(t2 / (2 * G)), l = (int)(t2 - (long)zi * 2 * G);
3571 const int y = l < G ? l : ny + l, z = G + zi;
3572 const int sy = y < G ? y + ny : y - ny;
3573 copyRow((long)y * e.x + (long)z * exy, (long)sy * e.x + (long)z * exy);
3574 } else { // the x-ghost cells of inner row (y, z)
3575 const long t3 = t - rZ - rY;
3576 const int zi = (int)(t3 / ny), yi = (int)(t3 - (long)zi * ny);
3577 const long base = (long)(G + yi) * e.x + (long)(G + zi) * exy;
3578 for (int x = 0; x < G; ++x)
3579 ff(base + x) = ff(base + x + nx);
3580 for (int x = G + nx; x < e.x; ++x)
3581 ff(base + x) = ff(base + x - nx);
3582 }
3583 };
3584 if (hostRunSerial(nZ + nY + nX)) {
3585 for (long t = 0; t < rZ + rY + rX; ++t)
3586 row(t);
3587 return;
3588 }
3589 Kokkos::parallel_for("peclet::flow::mg_pfill3",
3590 Kokkos::RangePolicy<CCExec>(space, 0, rZ + rY + rX), row);
3591 return;
3592 }
3593 CCField ff = f;
3594 Kokkos::parallel_for(
3595 "peclet::flow::mg_pfill3", Kokkos::RangePolicy<CCExec>(space, 0, nZ + nY + nX),
3596 KOKKOS_LAMBDA(long t) {
3597 int x, y, z;
3598 if (t < nZ) {
3599 const int l = (int)(t / exy);
3600 const long r = t - (long)l * exy;
3601 x = (int)(r % e.x);
3602 y = (int)(r / e.x);
3603 z = l < G ? l : nz + l;
3604 } else if (t < nZ + nY) {
3605 const long t2 = t - nZ, row = 2L * G * e.x;
3606 const long zi = t2 / row, r = t2 - zi * row;
3607 const int l = (int)(r / e.x);
3608 x = (int)(r % e.x);
3609 y = l < G ? l : ny + l;
3610 z = G + (int)zi;
3611 } else {
3612 const long t3 = t - nZ - nY, row = (long)ny * 2 * G;
3613 const long zi = t3 / row, r = t3 - zi * row;
3614 const int l = (int)(r % (2 * G));
3615 x = l < G ? l : nx + l;
3616 y = G + (int)(r / (2 * G));
3617 z = G + (int)zi;
3618 }
3619 const int sx = x < G ? x + nx : (x >= G + nx ? x - nx : x);
3620 const int sy = y < G ? y + ny : (y >= G + ny ? y - ny : y);
3621 const int sz = z < G ? z + nz : (z >= G + nz ? z - nz : z);
3622 ff((long)x + (long)y * e.x + (long)z * exy) =
3623 ff((long)sx + (long)sy * e.x + (long)sz * exy);
3624 });
3625 }
3627 fill(lv, lv.ox);
3628 fill(lv, lv.oy);
3629 fill(lv, lv.oz);
3630 }
3631 void fillAxis(Level& lv, CCField f, int axis) {
3632 CCExec space;
3633 C3 e = lv.ext;
3634 const int G = lv.g; // shadows the class constant: this level's ghost width
3635 int N3[3] = {lv.inner.x, lv.inner.y, lv.inner.z};
3636 int dims[3] = {e.x, e.y, e.z};
3637 long st[3] = {1, e.x, (long)e.x * e.y};
3638 const int a = axis, b = (axis + 1) % 3, c = (axis + 2) % 3;
3639 const long sa = st[a], sb = st[b], sc = st[c];
3640 const int N = N3[a];
3641 CCField ff = f;
3642 Kokkos::parallel_for(
3643 "peclet::flow::mg_pfill", MDRange2<CCExec>(space, {0, 0}, {dims[b], dims[c]}),
3644 KOKKOS_LAMBDA(int p0, int p1) {
3645 const long base = (long)p0 * sb + (long)p1 * sc;
3646 for (int gl = 0; gl < G; ++gl) {
3647 ff(base + (long)gl * sa) = ff(base + (long)(gl + N) * sa);
3648 ff(base + (long)(G + N + gl) * sa) = ff(base + (long)(G + gl) * sa);
3649 }
3650 });
3651 }
3652#ifdef PECLET_FLOW_MPI
3653 // ghost-projection matvec staging (solveBiCGStab distributed): copy the l0 inner cells onto the
3654 // caller's g=2 block (whose halo then carries the overlay's +/-2 reach) ...
3655 void stageG2(Level& l0, CCField q, CCField xg2, C3 ext2) {
3656 CCExec space;
3657 const C3 e1 = l0.ext, nn = l0.inner;
3658 CCField dst = xg2, src = q;
3659 Kokkos::parallel_for(
3660 "peclet::flow::gp_stage_g2", MDRange3<CCExec>(space, {0, 0, 0}, {nn.x, nn.y, nn.z}),
3661 KOKKOS_LAMBDA(int x, int y, int z) {
3662 dst((long)(x + 2) + (long)(y + 2) * ext2.x + (long)(z + 2) * (long)ext2.x * ext2.y) =
3663 src((long)(x + G) + (long)(y + G) * e1.x + (long)(z + G) * (long)e1.x * e1.y);
3664 });
3665 }
3666 // ... and read the whole l0 block (inner + its g=1 ring) back from the exchanged g=2 copy, so
3667 // the 7-point op's halo is current without a second exchange.
3668 void unstageG2(Level& l0, CCField q, CCField xg2) {
3669 CCExec space;
3670 const C3 e1 = l0.ext;
3671 const C3 ext2{e1.x + 2, e1.y + 2, e1.z + 2}; // same inner, gb 1 -> 2
3672 CCField dst = q, src = xg2;
3673 Kokkos::parallel_for(
3674 "peclet::flow::gp_unstage_g2", MDRange3<CCExec>(space, {0, 0, 0}, {e1.x, e1.y, e1.z}),
3675 KOKKOS_LAMBDA(int x, int y, int z) {
3676 dst((long)x + (long)y * e1.x + (long)z * (long)e1.x * e1.y) =
3677 src((long)(x + 1) + (long)(y + 1) * ext2.x + (long)(z + 1) * (long)ext2.x * ext2.y);
3678 });
3679 }
3680#endif
3681 void axpy(CCField y, double a, CCField x) {
3682 CCExec space;
3683 CCField yy = y, xx = x;
3684 std::size_t n = y.extent(0);
3685 Kokkos::parallel_for(
3686 "mgaxpy", Kokkos::RangePolicy<CCExec>(space, 0, n),
3687 KOKKOS_LAMBDA(std::size_t i) { yy(i) += a * xx(i); });
3688 }
3689 void aypx(CCField y, double a, CCField x) {
3690 CCExec space;
3691 CCField yy = y, xx = x;
3692 std::size_t n = y.extent(0);
3693 Kokkos::parallel_for(
3694 "mgaypx", Kokkos::RangePolicy<CCExec>(space, 0, n),
3695 KOKKOS_LAMBDA(std::size_t i) { yy(i) = xx(i) + a * yy(i); });
3696 }
3697 void scale(CCField y, double a) {
3698 CCExec space;
3699 CCField yy = y;
3700 std::size_t n = y.extent(0);
3701 Kokkos::parallel_for(
3702 "mgscale", Kokkos::RangePolicy<CCExec>(space, 0, n),
3703 KOKKOS_LAMBDA(std::size_t i) { yy(i) *= a; });
3704 }
3705 void lin(CCField out, double a, CCField x, double b, CCField y) { // out = a*x + b*y (mg_lin_k)
3706 CCExec space;
3707 CCField oo = out, xx = x, yy = y;
3708 std::size_t n = out.extent(0);
3709 Kokkos::parallel_for(
3710 "mglin", Kokkos::RangePolicy<CCExec>(space, 0, n),
3711 KOKKOS_LAMBDA(std::size_t i) { oo(i) = a * xx(i) + b * yy(i); });
3712 }
3713 // zero the solid-cell entries (AC<=tiny) -> project out the solid null modes (mg_mask_solid_k).
3714 void maskSolid(Level& lv, CCField f) {
3715 CCExec space;
3716 CCField ff = f;
3717 FPV ac = lv.AC;
3718 std::size_t n = f.extent(0);
3719 Kokkos::parallel_for(
3720 "mgmasksolid", Kokkos::RangePolicy<CCExec>(space, 0, n), KOKKOS_LAMBDA(std::size_t i) {
3721 if (!(ac(i) > 1e-30f))
3722 ff(i) = 0.0;
3723 });
3724 }
3725
3726 // A zero-filled level-0 work vector that persists across solves: the Chebyshev driver and its
3727 // eigenvalue estimate used to allocate (and free) eight of these per step -- a
3728 // cudaMalloc/cudaFree pair each, and cudaFree synchronises the device. Same contents as a fresh
3729 // View (zeros), so the solves are unchanged bit for bit.
3730 CCField workVector(int slot, std::size_t n, const char* label) {
3731 CCField& v = work_[slot];
3732 if (v.extent(0) != n)
3733 v = CCField(label, n);
3734 else
3735 Kokkos::deep_copy(CCExec(), v, 0.0);
3736 return v;
3737 }
3738
3739 // How estimateEigenvalues starts its two power iterations (D3, doc/vof_step_performance_design.md
3740 // §5.13). `Cold`: both from `seed` (the right-hand side), nothing kept -- the pre-D3 estimate,
3741 // and still the balanced-force pre-projection's. `ColdKeep`: the same arithmetic, and the last
3742 // v_max / v_min iterates are kept. `Warm`: each iteration starts from its kept iterate (masked,
3743 // mean-removed and normalised exactly as the rhs seed is) and the kept iterates are replaced by
3744 // the new last ones; `seed` is not read. Warm requires eigenWarmReady().
3745 enum class EigenStart { Cold, ColdKeep, Warm };
3746 // True when a ColdKeep or Warm estimate has run on the current hierarchy (init/initMpi drop it).
3747 bool eigenWarmReady() const {
3748 return eigWarm_ && !lv_.empty() && eigVmax_.extent(0) == lv_[0].n &&
3749 eigVmin_.extent(0) == lv_[0].n;
3750 }
3751 // The kept iterates are cross-step state: the Solver's `redistribute` carries them across a
3752 // repartition (inner cells migrated like a registry field) and hands them back here.
3753 CCField eigenWarmMax() const { return eigVmax_; }
3754 CCField eigenWarmMin() const { return eigVmin_; }
3756 eigVmax_ = vmax;
3757 eigVmin_ = vmin;
3758 eigWarm_ = true;
3759 }
3760
3761 // Estimate the spectral bounds [lmin,lmax] of M^{-1}A (M^{-1} = one symmetric V-cycle) by power
3762 // iteration (direct for the max + a shifted iteration for the min), seeded by `seed` (or, with
3763 // EigenStart::Warm, by the iterates a previous estimate kept). Communication-heavy, so the CUDA
3764 // driver runs it once on step 1 and reuses the bounds. Port of estimate_eigenvalues.
3765 void estimateEigenvalues(CCConst seed, double& lmin, double& lmax, int iters, int pre, int post,
3767 solveFailed_ = false; // ISSUES sweep item 6: per-solve breakdown flag
3768 pre_ = pre;
3769 post_ = post;
3770 bottom_ = bottom;
3771 Level& l0 = lv_[0];
3772 const std::size_t n = l0.n;
3773 CCField v = workVector(0, n, "ev_v"), w = workVector(1, n, "ev_w"),
3774 z = workVector(2, n, "ev_z"), srhs = workVector(3, n, "ev_srhs");
3775 const bool warm = start == EigenStart::Warm, keep = start != EigenStart::Cold;
3776 if (!warm)
3777 Kokkos::deep_copy(CCExec(), srhs, seed);
3778 auto matvec = [&](CCField y, CCField x) { matvecOverlap(l0, y, x); };
3779 auto applyT = [&](CCField out,
3780 CCField in) { // out = M^{-1} A in, projected onto the fluid range
3781 matvec(w, in);
3782 precondVcycle(out, w); // A5
3783 removeMean(l0, out);
3784 maskSolid(l0, out);
3785 };
3786 auto normalize = [&](CCField x) {
3787 double nr = std::sqrt(dot(l0, x, x));
3788 if (nr > 0)
3789 scale(x, 1.0 / nr);
3790 return nr;
3791 };
3792 auto seedf = [&](CCField x, CCField src) {
3793 Kokkos::deep_copy(CCExec(), x, src);
3794 removeMean(l0, x);
3795 maskSolid(l0, x);
3796 return normalize(x);
3797 };
3798 auto keepf = [&](CCField& dst, CCField x, const char* label) {
3799 if (dst.extent(0) != n)
3800 dst = CCField(label, n);
3801 Kokkos::deep_copy(CCExec(), dst, x);
3802 };
3803 const double nrMax = seedf(v, warm ? eigVmax_ : srhs);
3804 lmax = 1.0;
3805 for (int k = 0; k < iters; ++k) {
3806 applyT(z, v);
3807 lmax = dot(l0, v, z);
3808 Kokkos::deep_copy(CCExec(), v, z);
3809 normalize(v);
3810 }
3811 if (keep)
3812 keepf(eigVmax_, v, "ev_vmax");
3813 const double nrMin = seedf(v, warm ? eigVmin_ : srhs);
3814 double mu = 0.0;
3815 for (int k = 0; k < iters; ++k) {
3816 applyT(z, v);
3817 lin(z, lmax, v, -1.0, z); // z = lmax*v - T v
3818 mu = dot(l0, v, z);
3819 Kokkos::deep_copy(CCExec(), v, z);
3820 normalize(v);
3821 }
3822 if (keep) {
3823 keepf(eigVmin_, v, "ev_vmin");
3824 // Only a non-degenerate estimate may seed a warm one: a zero seed (a zero right-hand side,
3825 // e.g. a fluid at rest) leaves zero iterates and lmax = 0, from which a warm estimate would
3826 // return zero bounds -- and a Chebyshev solve on them NaN, which maxabs cannot see.
3827 eigWarm_ =
3828 nrMax > 0.0 && nrMin > 0.0 && std::isfinite(lmax) && lmax > 0.0 && std::isfinite(mu);
3829 }
3830 double e_hi = lmax, e_lo = lmax - mu; // direct (max) + shifted (min) Rayleigh estimates
3831 lmin = e_lo < e_hi ? e_lo : e_hi;
3832 lmax = e_lo < e_hi ? e_hi : e_lo; // robust min/max bracket
3833 if (lmin < 0.02 * lmax)
3834 lmin = 0.02 * lmax;
3835 }
3836
3837 // Chebyshev semi-iteration preconditioned by ONE symmetric V-cycle -- same goal as solvePCG but
3838 // the step coefficients come from the spectral bounds [a,b], so NO per-iteration global
3839 // dot-products (communication- light at scale). rhs on level-0 supplied as `b`; solution left in
3840 // `x`. Returns the V-cycle count. Port of solve_chebyshev.
3841 // `guard3` (D3, §5.13, for a solve on warm-started bounds): if the residual after 3 iterations
3842 // is not <= r0 the solve stops there and chebyshevGuardTripped() reports it, so the caller can
3843 // re-estimate cold and redo the solve. Off, the loop is the pre-D3 loop.
3844 int solveChebyshev(CCField b, CCField x, int maxit, double rtol, int pre, int post, int bottom,
3845 double a, double bnd, bool guard3 = false) {
3846 solveFailed_ = false; // ISSUES sweep item 6: per-solve breakdown flag
3847 chebGuardTripped_ = false;
3848 pre_ = pre;
3849 post_ = post;
3850 bottom_ = bottom;
3851 Level& l0 = lv_[0];
3852 const std::size_t n = l0.n;
3853 if (a > bnd) {
3854 double t = a;
3855 a = bnd;
3856 bnd = t;
3857 } // robust to swapped bounds
3858 a *= 0.95;
3859 bnd *= 1.05; // safety margin: [a,b] must bracket the spectrum
3860 CCField r = workVector(4, n, "cb_r"), z = workVector(5, n, "cb_z"),
3861 d = workVector(6, n, "cb_d"), w = workVector(7, n, "cb_w");
3862 auto matvec = [&](CCField y, CCField v) { matvecOverlap(l0, y, v); };
3863 auto precond = [&](CCField zz, CCField rr) { precondVcycle(zz, rr); }; // A5
3864 const double theta = 0.5 * (bnd + a), delta = 0.5 * (bnd - a), sigma1 = theta / delta;
3865 double rho = 1.0 / sigma1;
3866 matvec(w, x); // r = b - A x
3867 Kokkos::deep_copy(CCExec(), r, b);
3868 axpy(r, -1.0, w);
3869 removeMean(l0, r);
3870 const double r0 = maxabs(l0, r);
3871 // The stop reference: r0 (the default), or the caller's FULL right-hand-side norm when a
3872 // stop reference is set (setStopReference; only the balanced-force projection sets it).
3873 // With it unset rref IS r0, so `rtol * rref` is bit-for-bit the old test.
3874 const double rref = stopRef_ > 0.0 ? stopRef_ : r0;
3875 int nvc = 0;
3876 if (r0 > 0.0) {
3877 precond(z, r);
3878 ++nvc; // z = M^{-1} r
3879 lin(d, 1.0 / theta, z, 0.0, z);
3880 axpy(x, 1.0, d); // d = z/theta; x += d
3881 for (int i = 1; i < maxit; ++i) {
3882 matvec(w, d);
3883 axpy(r, -1.0, w);
3884 removeMean(l0, r); // r -= A d
3885 const double rn = maxabs(l0, r);
3886 if (rn < rtol * rref)
3887 break;
3888 if (guard3 && i == 3 && !(rn <= r0)) { // growing after 3 iterations: bounds gone stale
3889 chebGuardTripped_ = true;
3890 break;
3891 }
3892 precond(z, r);
3893 ++nvc;
3894 const double rho_new = 1.0 / (2.0 * sigma1 - rho);
3895 lin(d, rho_new * rho, d, 2.0 * rho_new / delta, z);
3896 axpy(x, 1.0, d); // d update; x += d
3897 rho = rho_new;
3898 }
3899 }
3900 removeMean(l0, x);
3901 return nvc;
3902 }
3903 // --- stop reference for a solve relative to the FULL right-hand side (the balanced-force
3904 // projection, flow doc/collocated_varrho_forces.md §4.6 / WO-P5). ref <= 0 restores the
3905 // default (stop relative to the initial residual). Callers set it around ONE solve only.
3906 void setStopReference(double ref) { stopRef_ = ref; }
3907 // Did the last solveChebyshev(..., guard3 = true) stop on its 3-iteration growth guard?
3908 bool chebyshevGuardTripped() const { return chebGuardTripped_; }
3909 // max|b| over the fluid cells after the null-space (mean) projection -- the drivers' norm.
3911 Level& l0 = lv_[0];
3912 Kokkos::deep_copy(scratch, b);
3914 return maxabs(l0, scratch);
3915 }
3916 // max|b - A x| in the same norm, computed exactly as the drivers compute their r0.
3918 Level& l0 = lv_[0];
3919 matvecOverlap(l0, Ap, x);
3920 Kokkos::deep_copy(r, b);
3921 axpy(r, -1.0, Ap);
3922 removeMean(l0, r);
3923 return maxabs(l0, r);
3924 }
3925 // reductions / mean removal over inner FLUID cells (AC>tiny) of a level.
3926 double dot(Level& lv, CCField a, CCField b) {
3927 CCExec space;
3928 C3 e = lv.ext;
3929 const int g = lv.g;
3930 CCField aa = a, bb = b;
3931 FPV ac = lv.AC;
3932 double s = 0;
3933 ccReduce3(
3934 "mgdot", C3{g, g, g}, C3{e.x - g, e.y - g, e.z - g},
3935 KOKKOS_LAMBDA(int x, int y, int z, double& acc) {
3936 const long i = (long)x + (long)y * e.x + (long)z * (long)e.x * e.y;
3937 if (ac(i) > 1e-30f)
3938 acc += aa(i) * bb(i);
3939 },
3940 s);
3941#ifdef PECLET_FLOW_MPI
3942 return allreduce(s, MPI_SUM_, lv.comm);
3943#else
3944 return allreduce(s, MPI_SUM_);
3945#endif
3946 }
3947 double maxabs(Level& lv, CCField a) {
3948 CCExec space;
3949 C3 e = lv.ext;
3950 const int g = lv.g;
3951 CCField aa = a;
3952 FPV ac = lv.AC;
3953 double m = 0;
3954 ccReduce3(
3955 "mgmax", C3{g, g, g}, C3{e.x - g, e.y - g, e.z - g},
3956 KOKKOS_LAMBDA(int x, int y, int z, double& acc) {
3957 const long i = (long)x + (long)y * e.x + (long)z * (long)e.x * e.y;
3958 if (ac(i) > 1e-30f) {
3959 const double v = Kokkos::fabs(aa(i));
3960 if (v > acc)
3961 acc = v;
3962 }
3963 },
3964 Kokkos::Max<double>(m));
3965#ifdef PECLET_FLOW_MPI
3966 return allreduce(m, MPI_MAX_, lv.comm);
3967#else
3968 return allreduce(m, MPI_MAX_);
3969#endif
3970 }
3971 // Single rank (A6, §5.6): the {sum, count} reduce into device slots with the same policy and
3972 // functor, and the subtract kernel forms mean = sum / count itself -- no host read. With
3973 // `stopGuard` the subtract is skipped once the PCG stop flag is set (solvePCGResident keeps r
3974 // exactly as the host-scalar loop leaves it at a break). Distributed: the Allreduce path below.
3975 void removeMean(Level& lv, CCField f, bool stopGuard = false) {
3976 if (!removeMean_)
3977 return; // non-singular operator (Dirichlet outflow present) -> no null-space projection
3978 CCExec space;
3979 C3 e = lv.ext;
3980 const int g = lv.g;
3981 CCField ff = f;
3982 FPV ac = lv.AC;
3983 // the {sum, count} body; ccReduce3 runs it in B2's pencil order on a host backend (§5.8) and
3984 // as the MDRange reduction it always was on a device
3985 auto body = KOKKOS_LAMBDA(int x, int y, int z, double& s, long& k) {
3986 const long i = (long)x + (long)y * e.x + (long)z * (long)e.x * e.y;
3987 if (ac(i) > 1e-30f) {
3988 s += ff(i);
3989 k += 1;
3990 }
3991 };
3992 if (!distributed_) {
3993 ensureScalars();
3994 ccReduce3("mgmeanr", C3{g, g, g}, C3{e.x - g, e.y - g, e.z - g}, body,
3995 Kokkos::Sum<double, CCMem>(slot(kMsum)), Kokkos::Sum<long, CCMem>(kcnt_));
3996 auto ks = ks_;
3997 auto kc = kcnt_;
3998 const bool guard = stopGuard;
3999 ccFor3(
4000 "mgmeans", C3{g, g, g}, C3{e.x - g, e.y - g, e.z - g},
4001 KOKKOS_LAMBDA(int x, int y, int z) {
4002 const long cnt = kc();
4003 if (cnt == 0 || (guard && ks(kStop) != 0.0))
4004 return;
4005 const double mean = ks(kMsum) / (double)cnt;
4006 const long i = (long)x + (long)y * e.x + (long)z * (long)e.x * e.y;
4007 meanSubtractCell(ff, ac, i, mean);
4008 });
4009 return;
4010 }
4011 double sum = 0;
4012 long cnt = 0;
4013 ccReduce3("mgmeanr", C3{g, g, g}, C3{e.x - g, e.y - g, e.z - g}, body, sum, cnt);
4014 double dcnt = (double)cnt;
4015 allreduceSum2(sum, dcnt
4017 ,
4018 lv.comm
4019#endif
4020 ); // ONE latency hit for the {sum, count} pair (was two)
4021 cnt = (long)dcnt;
4022 if (cnt == 0)
4023 return;
4024 const double mean = sum / (double)cnt;
4025 ccFor3(
4026 "mgmeans", C3{g, g, g}, C3{e.x - g, e.y - g, e.z - g}, KOKKOS_LAMBDA(int x, int y, int z) {
4027 const long i = (long)x + (long)y * e.x + (long)z * (long)e.x * e.y;
4028 meanSubtractCell(ff, ac, i, mean);
4029 });
4030 }
4031
4032 // Mean-removal scope. "all" (legacy): project the nullspace out at every V-cycle level,
4033 // after every matvec and on every residual update — ~10 extra MPI_Allreduce latency hits per
4034 // Krylov iteration whose only role is FP hygiene. "fine" (DEFAULT) keeps the removals that carry
4035 // the algorithm (the rhs/residual projections + the fine-level V-cycle exit + the final iterate)
4036 // and drops the interior-level ones: A maps mean-free vectors to mean-free vectors, so the Krylov
4037 // space never sees the dropped components (they lie in the nullspace and are removed from the
4038 // final x). Validated by iteration-count parity; not bit-identical to "all".
4039 void setMeanRemovalScope(bool all) { meanRemovalAll_ = all; }
4040
4041 // Accumulated wall time / call count of the global reductions (every dot product, residual max
4042 // and mean-removal funnels through allreduce()). This is THE latency-bound term of the
4043 // distributed pressure solve; the solver resets it per step and exposes it to Python.
4044 double allreduceSeconds() const { return allreduceTime_; }
4045 long allreduceCount() const { return allreduceCount_; }
4046 // ISSUES sweep item 6: did the LAST solve driver give up on a non-finite recurrence scalar?
4047 // A failing solve returns the ITERATION CAP (so a rule-3b "no capped solve" check sees it) and
4048 // sets this; it used to print to stdout, zero the correction and report 0 iterations.
4049 // The device bottom flags a non-finite scalar on the device; a driver whose host reads do not
4050 // carry it (all but the single-rank PCG packet) has it folded in here, by one scalar read.
4052 if (bottomFlagPending_) {
4053 double v = 0.0;
4054 Kokkos::deep_copy(v, slot(kBottomFlag));
4055 noteBottomFlag(v);
4056 }
4057 return solveFailed_;
4058 }
4060 allreduceTime_ = 0.0;
4061 allreduceCount_ = 0;
4062 }
4063
4064 private:
4065 enum AllOp { kSum, kMax };
4066#ifdef PECLET_FLOW_MPI
4067 using MPI_Comm_or_void = MPI_Comm;
4068 static MPI_Comm nullptr_comm() { return MPI_COMM_NULL; }
4069#else
4070 using MPI_Comm_or_void = void*;
4071 static void* nullptr_comm() { return nullptr; }
4072#endif
4073 // Global reduction over ranks (no-op single-rank / non-distributed -> byte-identical to the local
4074 // reduce).
4075 double allreduce(double v, AllOp op, MPI_Comm_or_void c = nullptr_comm()) {
4076#ifdef PECLET_FLOW_MPI
4077 if (distributed_) {
4078 const auto t0 = std::chrono::steady_clock::now();
4079 double g = 0;
4080 MPI_Allreduce(&v, &g, 1, MPI_DOUBLE, op == kSum ? MPI_SUM : MPI_MAX,
4081 c == nullptr_comm() ? comm_ : c);
4082 allreduceTime_ +=
4083 std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count();
4084 ++allreduceCount_;
4085 return g;
4086 }
4087#endif
4088 (void)op;
4089 return v;
4090 }
4091 // One MPI_Allreduce of a {sum, count} pair — elementwise MPI_SUM on a 2-vector is bit-identical
4092 // to two separate allreduces, at half the latency hits.
4093 void allreduceSum2(double& a, double& b, MPI_Comm_or_void c = nullptr_comm()) {
4094#ifdef PECLET_FLOW_MPI
4095 if (distributed_) {
4096 const auto t0 = std::chrono::steady_clock::now();
4097 double v[2] = {a, b}, g[2] = {0.0, 0.0};
4098 MPI_Allreduce(v, g, 2, MPI_DOUBLE, MPI_SUM, c == nullptr_comm() ? comm_ : c);
4099 allreduceTime_ +=
4100 std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count();
4101 ++allreduceCount_;
4102 a = g[0];
4103 b = g[1];
4104 }
4105#endif
4106 }
4107 static constexpr AllOp MPI_SUM_ = kSum, MPI_MAX_ = kMax;
4108
4109 std::vector<Level> lv_;
4110 CCField work_[8]; // workVector's slots (0-3 the eigenvalue estimate, 4-7 the Chebyshev driver)
4111 // D3 (§5.13): the last v_max / v_min power iterates of a ColdKeep/Warm estimate, persistent
4112 // across solves (never zeroed), valid while eigWarm_ (dropped by init/initMpi).
4113 CCField eigVmax_, eigVmin_;
4114 bool eigWarm_ = false;
4115 bool chebGuardTripped_ = false; // see chebyshevGuardTripped()
4116 // The physical metric (setMetric), doc/anisotropic_metric.md §5: h_a' = h_a/hRef and the flag
4117 // that engages the aspect-ratio coarsening rule. (1,1,1)/false is the isotropic lattice, on
4118 // which mgChooseRatio reproduces today's level table exactly.
4119 double hp_[3] = {1.0, 1.0, 1.0};
4120 bool aniso_ = false;
4121 int pre_ = 2, post_ = 2, bottom_ = 4;
4122 int bc_[6] = {0, 0, 0, 0, 0, 0};
4123 bool hasBC_ = false, removeMean_ = true, hasOutflow_ = false;
4124 // WO-R2 item 1. OFF by default and then every path is byte-identical to before it existed: the
4125 // outflow face keeps the literal 1.0 the raw-openness operator wants. The varRho pressure build
4126 // (IbmSolver::project) turns it on for its own setOpenness call, because the field it hands over
4127 // is the COEFFICIENT open_f*rho0/rho_f and the 1.0 would overwrite the boundary rows of it.
4128 bool outflowCoeff_ = false;
4129 CCField bcPlane_[3]; // level-0 high-side outflow coefficient plane, per axis (saveOutflowPlanes)
4130 // Default "fine" (measured winner of the at-scale ablation, Snellius H100 8+16 GPUs: 5.5%
4131 // faster than "all" with identical iteration counts; single-rank the reductions are free either
4132 // way). setMeanRemovalScope(true) restores the legacy every-level scope.
4133 bool meanRemovalAll_ = false;
4134 bool distributed_ = false;
4135 bool overlaySolve_ = false; // A3: the current solve has an overlay (OverlayScope)
4136 int dbgBrkWhich_ = 0, dbgBrkIter_ = -1; // setDebugBreakdown (test hook)
4137 int dbgSolve_ = 0; // solve counter for the env-gated convergence trace (mgDebugLevel() >= 2)
4138 // ISSUES sweep item 6: did the LAST Krylov solve give up on a non-finite recurrence
4139 // scalar (a preconditioner or operator that produced NaN/Inf)? Reset at the head of
4140 // every solve. See `lastSolveFailed()`.
4141 bool solveFailed_ = false;
4142 // The device bottom (§5.7, §5.14, §13): the FCG vectors, the kernel's info slot, the component
4143 // labels (condition 6, cached per hierarchy), and whether a device solve ran since its flag was
4144 // last read.
4145 CCField bottomR_, bottomP_, bottomZ_, bottomZp_, bottomAp_;
4146 Kokkos::View<int*, CCMem> bottomInfo_; // [0] = inner iterations of the last device solve
4147 bool bottomConnKnown_ = false, bottomConnected_ = false; // condition 6 (B1b), per hierarchy
4148 int bottomNComp_ = 0; // the bottom's fluid components
4149 Kokkos::View<int*, CCMem> bottomComp_, bottomLabTmp_; // component ids (-1 solid), label scratch
4150 Kokkos::View<long*, CCMem> bottomCompCnt_; // cells per component
4151 CCField bottomCompMean_; // the kernel's per-component means
4152 bool bottomFlagPending_ = false; // a device solve ran since the device flag was last read
4153 int bottomSolver_ = 0; // kBottomAuto / kBottomDirect / kBottomAlgebraic
4154 // §13, the direct preconditioner: the bottom storage exists (single rank, <= 8192 cells, device
4155 // or forced by a hook), the plane ordering, the FP32 factor, the per-component augmentation, the
4156 // factor's staleness (set by setOpenness), the two kernels' team sizes, the first attempt's
4157 // pivot floor (kBottomPivotTol; the U5 hook raises it).
4158 bool bottomStore_ = false;
4159 BottomPlanes dirPlanes_;
4160 BottomDirect<float> dir_;
4161 Kokkos::View<int*, CCMem> dirKc_;
4162 Kokkos::View<double*, CCMem> dirAug_;
4163 bool facStale_ = true;
4164 int dirTeam_ = 0, dirFacTeam_ = 0;
4165 double dirPivotTol_ = kBottomPivotTol;
4166 double stopRef_ = -1.0; // see setStopReference
4167 std::vector<double> lvTime_; // per-level V-cycle wall time (mgDebugLevel() >= 3)
4168 int lvCycles_ = 0;
4169 // Zero-gradient (Neumann) coarse ghost before the prolongation on wall/inflow faces — the WO-H
4170 // symmetry repair (see applyNeumannGhost). ON by default; `setCoarseGhost(false)` restores the
4171 // pre-2026-08-30 periodic-wrap ghost purely as a MEASUREMENT ABLATION (it reinstates the
4172 // asymmetry that stalls MG-PCG on every 3-D wall-bounded grid — never a production setting).
4173 // Inert on periodic/IBM problems (hasBC_ == false), where the fix is a no-op either way.
4174 bool bcGhost_ = true;
4175 // The aspect-ratio coarsening threshold `theta` of doc/anisotropic_metric.md §5.1
4176 // (`setAspectThreshold`; only ever consulted on an ANISOTROPIC metric). 1e9 restores full
4177 // coarsening (the §8.5 item (c) ablation), 1.4142 is the sqrt(2) variant §5.2 weighs against 2.0.
4178 double aspectTheta_ = 2.0;
4179 // Communication-avoiding smoothing (see the kCa* enum): both subsystems on by default. Only
4180 // kCaMg is read here; the momentum half lives in IbmSolver.
4181 int caMode_ = kCaBoth;
4182 // ISSUES sweep item 6. `setStrictPressure(true)` turns a non-finite preconditioner output into a
4183 // throw instead of a printed line + a zero correction. Off by default: the shipped behaviour is
4184 // to report the cap through `last_pressure_iterations()` and raise the `pressure_solve_failed()`
4185 // flag, so a rule-3b check catches it without changing control flow.
4186 bool strictPressure_ = false;
4187 // P1 of the defect-correction campaign — the exact (matrix-free, double, flux-form) level-0
4188 // operator apply in the residual and the Krylov matvec. See mac_cutcell.hpp.
4189 bool exactResidual_ = false;
4190 // The `agglomerateBottom()` auto criterion: agglomerate once the coarsest GLOBAL grid exceeds
4191 // this many cells on any axis (`setAgglomerationExtent`).
4192 int agglomExtent_ = 4;
4193 double allreduceTime_ = 0.0;
4194 long allreduceCount_ = 0;
4195 // --- decomposition-agnostic algebraic bottom solve (GraphAMG) ---
4196 // The geometric coarse hierarchy needs a cleanly-coarsening (equal-weight) ORB. Under a WEIGHTED
4197 // decomposition the coarse levels misalign, so the multilevel path is unavailable and only pure
4198 // RB-GS (nLevels==1) works. With this enabled, the coarsest level is solved by an AGGLOMERATED
4199 // algebraic multigrid: the operator + rhs of the coarsest level are gathered to rank 0, solved by
4200 // a mesh-agnostic smoothed-aggregation AMG (core::solver::GraphAMG, exact by construction on any
4201 // decomposition), and the solution scattered back. With nLevels==1 this makes the whole pressure
4202 // solve mesh-independent AND decomposition-agnostic.
4203 int agglomMode_ = 0; // 0 smoothed bottom (default), -1 auto (see agglomerateBottom), 1 always
4204 // Coarse-level telescoping (see Telescope). DEFAULT ON since 2026-09-02 (the FoxBerry ladder);
4205 // `setTelescope(false)` restores the in-place-only hierarchy, which is byte-identical to before
4206 // telescoping existed. teleForce_ > 0 forces a telescope at that level even when in-place
4207 // coarsening is legal (tests: compare the two hierarchies on one problem).
4208 bool telescope_ = true;
4209 int teleForce_ = -1;
4210 bool teleActive_ = true; // false on a rank that idles below a telescope point
4211 // Economic trigger (MueLu's "min rows per proc", PETSc's reduction factor): once a level's
4212 // smallest block extent drops below this, merge -- and merge far enough that the merged blocks
4213 // clear it -- even if in-place coarsening is still legal. A 1x3x3 block on 1024 ranks is a
4214 // halo exchange with nine cells of work behind it. 0 disables (merge only when blocked).
4215 int teleMinExtent_ = 4;
4216 // Repartition stages (setRepartition): bound the block a telescope stage may hand a rank by the
4217 // largest level-0 block, repartitioning the level onto a proportional ORB on fewer ranks where a
4218 // sibling merge would exceed it. OFF by default (sibling merges only, byte-identical to S4); the
4219 // Solver switches it on for a weighted level-0 decomposition.
4220 bool repartition_ = false;
4221 int gnxF_ = 0, gnyF_ = 0, gnzF_ = 0; // GLOBAL fine dims (== local single-rank)
4222 // Fine-level 1/h^2 per axis, as handed to setOpenness. Only the exact (matrix-free) level-0
4223 // apply reads them; the bands carry gf already folded in.
4224 double gfx_ = 1.0, gfy_ = 1.0, gfz_ = 1.0;
4225 mutable std::shared_ptr<peclet::core::solver::GraphAMG>
4226 amg_; // built once from the bottom operator
4227 mutable peclet::core::solver::HostCsrOp
4228 amgA_; // rank 0: the assembled global bottom operator (CG matvec)
4229 mutable std::vector<int> amgOwnerCount_; // rank 0: #bottom cells each rank owns (gather layout)
4230 mutable std::vector<int>
4231 amgGlobalOfLocal_; // this rank's bottom inner cells -> global bottom index
4232 mutable int amgGlobalN_ = 0; // total bottom global cells (rank 0)
4233 // PECLET_FLOW_AGMG_DEBUG instrumentation (see agmgDebug()): per-gid solid marker + call counter.
4234 mutable std::vector<std::uint8_t> amgSolid_;
4235 mutable std::vector<int> amgComp_; // fluid component id per gid (-1 = solid identity row)
4236 mutable int amgNComp_ = 0; // number of fluid components (null-space dimension)
4237 mutable long agmgCalls_ = 0;
4238 static int agmgDebug() {
4239 static const int v = [] {
4240 const char* e = std::getenv("PECLET_FLOW_AGMG_DEBUG");
4241 return e ? std::atoi(e) : 0;
4242 }();
4243 return v;
4244 }
4245#ifdef PECLET_FLOW_MPI
4246 MPI_Comm comm_ = MPI_COMM_NULL;
4247#endif
4248
4249 public:
4250 // Enable the agglomerated GraphAMG bottom solve (decomposition-agnostic multigrid coarse solve).
4251 // Rebuilds lazily on the next solve. Safe single-rank (local assemble + serial AMG).
4252 void setAgglomerationMode(int mode) { agglomMode_ = mode; } // -1 auto, 0 never, 1 always
4253 void setAgglomerationExtent(int cells) { agglomExtent_ = cells > 0 ? cells : 4; }
4254 int agglomerationExtent() const { return agglomExtent_; }
4255 void setTelescope(bool on) { telescope_ = on; }
4257 void setCoarseGhost(bool on) { bcGhost_ = on; }
4258 bool coarseGhost() const { return bcGhost_; }
4260 void setAspectThreshold(double theta) { aspectTheta_ = theta; }
4261 double aspectThreshold() const { return aspectTheta_; }
4263 void setCommAvoiding(int mask) { caMode_ = mask; }
4264 int commAvoiding() const { return caMode_; }
4266 void setStrictPressure(bool on) { strictPressure_ = on; }
4267 bool strictPressure() const { return strictPressure_; }
4269 void setExactResidual(bool on) { exactResidual_ = on; }
4270 bool exactResidual() const { return exactResidual_; }
4271 // WO-R2 item 1: the next setOpenness receives a variable-density COEFFICIENT field, so the
4272 // Dirichlet (outflow) domain-face rows must carry the caller's coefficient rather than the
4273 // literal openness 1.0. See applyBoundaryOpenness. Reset it for a raw-openness build.
4274 void setOutflowCoefficient(bool on) { outflowCoeff_ = on; }
4275 bool outflowCoefficient() const { return outflowCoeff_; }
4276 // Diagnostics for the tests / the prediction tool: telescope points this rank passed through,
4277 // and the coarsest GLOBAL grid of the hierarchy (valid on a rank that holds every level, e.g.
4278 // rank 0, which is always a group root).
4279 int telescopeCount() const {
4280 int n = 0;
4281#ifdef PECLET_FLOW_MPI
4282 for (const Level& l : lv_)
4283 n += l.tele ? 1 : 0;
4284#endif
4285 return n;
4286 }
4287 // Repartition stages this rank passed through (a subset of telescopeCount()).
4288 int repartitionCount() const {
4289 int n = 0;
4290#ifdef PECLET_FLOW_MPI
4291 for (const Level& l : lv_)
4292 n += (l.tele && l.tele->repartition()) ? 1 : 0;
4293#endif
4294 return n;
4295 }
4297 C3 g{gnxF_, gnyF_, gnzF_};
4298 for (std::size_t L = 0; L + 1 < lv_.size(); ++L)
4299 g = C3{g.x / lv_[L].ratio.x, g.y / lv_[L].ratio.y, g.z / lv_[L].ratio.z};
4300 return g;
4301 }
4302#ifdef PECLET_FLOW_MPI
4303 // The hierarchy initMpi WOULD build, as a pure function of (grid, rank count, levels, telescope,
4304 // decomposition mode) — no MPI, no allocation, so it runs on a laptop for any rank count. One row
4305 // per level: global dims, ranks holding it, block dims (block 0), ratio to the next level, and
4306 // whether the transition out of it telescopes. This is the pre-flight for a job (P1 of
4307 // docs/MG_TELESCOPING_PLAN.md) and replaces launching `np` oversubscribed processes to find out.
4308 // NOTE (Phase 2 C3): this planner models the ISOTROPIC rule -- it takes no metric, so it does
4309 // NOT apply the aspect-ratio deferral of doc/anisotropic_metric.md §5. On an anisotropic
4310 // `extent` read the real table off the built hierarchy instead
4311 // (`Solver.pressure_mg_level_ratios()` / `CutcellMG::levelRatios()`). Threading `hp` through
4312 // here and through scripts/check_decomposition.py is a follow-up the note does not order.
4313 struct PlanRow {
4314 C3 global, block, ratio;
4315 int ranks;
4316 bool tele;
4317 bool repartition = false; // the stage out of this level is a Repartition (tele is set too)
4318 };
4319 // `decompLevels`/`maxImbalance` are the LEVEL-0 decomposition's parameters (what
4320 // Solver::setDecomposition takes), not the MG level count `nLevels`: predict is a pure function,
4321 // so a caller pre-flighting a coarse-first job must state the depth it will run with. Before the
4322 // env-var retirement these came from a process-global static, which is exactly why they are
4323 // parameters now.
4324 //
4325 // `weights` (optional) are per-cell weights of the global grid, x-fastest (gnx*gny*gnz) -- what
4326 // Solver::rebalanceByWeights takes -- and the forecast is then the hierarchy AFTER that call:
4327 // level 0 is the partition it builds, core's aligned weighted ORB `chooseAlignedWeighted(np, G,
4328 // w)` at its 1.05 budget (`decompLevels` / `maxImbalance` are ignored, as the rebalance ignores
4329 // Solver::setDecomposition), and the stages are those the Solver arms for a weighted dec0
4330 // (setRepartition(true): `chooseStageTarget` with maxBlockCells = the largest level-0 block, so
4331 // a stage may be a Repartition). `*align`, when given, receives that partition's alignment 2^a
4332 // -- rebalanceByWeights' return value. Without weights the forecast is unchanged.
4333 static std::vector<PlanRow> predict(int gnx, int gny, int gnz, int np, int nLevels,
4334 bool telescope, int minExtent = 4, int decompLevels = 0,
4335 double maxImbalance = 1.05,
4336 const std::vector<peclet::core::Real>* weights = nullptr,
4337 int* align = nullptr) {
4338 using Dec = peclet::core::decomp::BlockDecomposer<3>;
4339 std::vector<PlanRow> rows;
4340 auto can = [](int d) { return (d % 2 == 0) && (d / 2 >= 2); };
4341 auto evenOn = [](const Dec& d, int ax) {
4342 for (std::size_t b = 0; b < d.sizes().size(); ++b)
4343 if ((d.origins()[b][ax] % 2) || (d.sizes()[b][ax] % 2))
4344 return false;
4345 return true;
4346 };
4347 Dec cur;
4348 peclet::core::Index maxBlockCells = 0; // 0: sibling merges only (an unweighted dec0)
4349 if (weights) {
4350 if (weights->size() != (std::size_t)gnx * (std::size_t)gny * (std::size_t)gnz)
4351 throw std::invalid_argument(
4352 "predict: weights must hold one value per global cell (gnx*gny*gnz, x-fastest)");
4353 auto c = peclet::core::decomp::chooseAlignedWeighted(
4354 static_cast<std::size_t>(np), peclet::core::IVec<3>{gnx, gny, gnz}, *weights);
4355 cur = std::move(c.dec);
4356 maxBlockCells = peclet::core::decomp::largestBlockCells(cur);
4357 if (align)
4358 *align = 1 << c.a;
4359 } else {
4360 cur = decomposition(static_cast<std::size_t>(np), gnx, gny, gnz, decompLevels, maxImbalance);
4361 if (align)
4362 *align = 1;
4363 }
4364 C3 gs{gnx, gny, gnz};
4365 for (int L = 0; L < nLevels; ++L) {
4366 PlanRow r;
4367 r.global = gs;
4368 r.ranks = (int)cur.numBlocks();
4369 r.block = C3{(int)cur.sizes()[0][0], (int)cur.sizes()[0][1], (int)cur.sizes()[0][2]};
4370 r.tele = false;
4371 r.ratio = C3{1, 1, 1};
4372 C3 next = gs;
4373 if (L + 1 < nLevels) {
4374 const bool canAny = can(gs.x) || can(gs.y) || can(gs.z);
4375 auto liftable = [&](const Dec& d) { return teleLiftable(d, gs); };
4376 const bool blocked = !liftable(cur);
4377 const bool tooSmall = minExtent > 0 && peclet::core::decomp::minBlockExtent(cur) <
4378 (peclet::core::Index)minExtent;
4379 // initMpi's trigger + core's policy (maxBlockCells = 0: flow's sibling-merge search,
4380 // which never returns a Repartition; > 0 for a weighted dec0, as setRepartition arms it)
4381 if (telescope && (blocked || tooSmall) && canAny && cur.numBlocks() > 1) {
4382 auto t = peclet::core::decomp::chooseStageTarget(
4383 cur, peclet::core::IVec<3>{gs.x, gs.y, gs.z}, liftable, minExtent, maxBlockCells);
4384 if (t.kind == peclet::core::decomp::StageKind::SiblingMerge ||
4385 t.kind == peclet::core::decomp::StageKind::Repartition) {
4386 r.repartition = t.kind == peclet::core::decomp::StageKind::Repartition;
4387 cur = std::move(t.dec);
4388 r.tele = true;
4389 }
4390 }
4391 if (can(gs.x) && evenOn(cur, 0)) {
4392 r.ratio.x = 2;
4393 next.x = gs.x / 2;
4394 }
4395 if (can(gs.y) && evenOn(cur, 1)) {
4396 r.ratio.y = 2;
4397 next.y = gs.y / 2;
4398 }
4399 if (can(gs.z) && evenOn(cur, 2)) {
4400 r.ratio.z = 2;
4401 next.z = gs.z / 2;
4402 }
4403 }
4404 rows.push_back(r);
4405 if (next.x == gs.x && next.y == gs.y && next.z == gs.z)
4406 break;
4407 gs = next;
4408 cur = cur.coarsened(peclet::core::IVec<3>{r.ratio.x, r.ratio.y, r.ratio.z});
4409 }
4410 return rows;
4411 }
4412 // The hierarchy initMpi BUILT, in predict()'s row format -- the oracle predict is tested
4413 // against. Rank 0 holds every level (it is the root of every stage), so there this is the whole
4414 // table; a rank idling below a stage gets the levels it holds.
4415 std::vector<PlanRow> builtPlan() const {
4416 std::vector<PlanRow> rows;
4417 for (const Level& l : lv_) {
4418 PlanRow r;
4419 r.global = l.gdim;
4420 r.block = l.inner;
4421 r.ratio = l.ratio;
4422 r.ranks = 1;
4423 if (l.comm != MPI_COMM_NULL)
4424 MPI_Comm_size(l.comm, &r.ranks);
4425 r.tele = (bool)l.tele;
4426 r.repartition = l.tele && l.tele->repartition();
4427 rows.push_back(r);
4428 }
4429 return rows;
4430 }
4431#endif // PECLET_FLOW_MPI (predict)
4432 bool telescope() const { return telescope_; }
4433 void setTelescopeForceLevel(int L) { teleForce_ = L; } // tests only; -1 = never force
4434 void setTelescopeMinExtent(int e) { teleMinExtent_ = e; }
4436 void setRepartition(bool on) { repartition_ = on; }
4437 bool repartition() const { return repartition_; }
4438 // Number of levels THIS rank holds (fewer than the hierarchy's on a rank idling below a
4439 // telescope point) and the hierarchy's rank count per level (replicated).
4440 int localLevels() const { return (int)lv_.size(); }
4441 int agglomerationMode() const { return agglomMode_; }
4443 agglomMode_ = on ? 1 : 0;
4444 amg_.reset();
4445 }
4446 // Engine selection for an agglomerated bottom (§13 D-5, §14 H-1): kBottomAuto = the FCG bottom
4447 // preconditioned by the direct factor wherever directBottomIneligible() is null (every backend
4448 // since §14 H-1), GraphAMG otherwise; kBottomDirect = that engine or a raise naming the failed
4449 // condition; kBottomAlgebraic = GraphAMG always (the A/B instrument).
4450 enum : int { kBottomAuto = 0, kBottomDirect = 1, kBottomAlgebraic = 2 };
4451 void setBottomSolver(int m) { bottomSolver_ = m; }
4452 int bottomSolver() const { return bottomSolver_; }
4453 // Test hooks (tests/kokkos/test_bottom_direct.cpp). Since §14 H-1 every backend builds the
4454 // bottom's storage at init (single rank); `bottomForceForTest` builds it (and labels the
4455 // components) if it is missing.
4456 bool bottomConnected() const { return bottomConnected_; }
4457 int bottomComponents() const { return bottomNComp_; }
4459 if (!bottomStore_) {
4462 }
4463 }
4464 // §13 hooks.
4465 // `directFactorForTest<FR>`: a fresh factor of the bottom in FR arithmetic (team T, 0 = the
4466 // default), `*Tused` = the team size it ran with; `teamAlgorithm`: by the team algorithm even on
4467 // a host backend (the host schedule's oracle, U8). `directApplyForTest`: z = M(r) by that factor
4468 // through the FCG kernel's precondition-only path (`noMean`: without the component means'
4469 // removal); returns the team size. `directSolveForTest`: the production solve (FP32 factor,
4470 // lazily refactored) on the bottom's rhs; returns the inner iterations.
4471 const BottomPlanes& directPlanes() const { return dirPlanes_; }
4472 template <class FR>
4474 int* Tused = nullptr, bool teamAlgorithm = false) {
4475 BottomDirect<FR> D = BottomDirect<FR>::allocate(dirPlanes_, lv_.back().ext);
4476 const int T = launchDirectFactor(D, team, tauPiv0, true, teamAlgorithm);
4477 Kokkos::fence();
4478 if (Tused)
4479 *Tused = T;
4480 return D;
4481 }
4482 template <class FR>
4484 bool noMean = false) {
4485 const int T = launchDirectKernel(D, true, team, noMean, z, r);
4486 Kokkos::fence();
4487 return T;
4488 }
4491 int it = 0;
4492 Kokkos::deep_copy(it, Kokkos::subview(bottomInfo_, 0));
4493 return it;
4494 }
4495 // §14 H-1 (U3 on a host backend): refactor and solve the bottom's rhs at team size `team`
4496 // (clamped to team_size_max), `*Tused` = the size both launches ran with (-1 if they differ);
4497 // returns the inner iterations. The production team sizes are re-probed at the next solve.
4498 int directSolveTeamForTest(int team, int* Tused = nullptr) {
4499 const int Tf = launchDirectFactor(dir_, team, dirPivotTol_);
4500 const int Ts = launchDirectKernel(dir_, false, team, false);
4501 bottomFlagPending_ = true;
4502 Kokkos::fence();
4503 if (Tused)
4504 *Tused = (Tf == Ts) ? Ts : -1;
4505 dirTeam_ = 0;
4506 dirFacTeam_ = 0;
4507 facStale_ = true;
4508 int it = 0;
4509 Kokkos::deep_copy(it, Kokkos::subview(bottomInfo_, 0));
4510 return it;
4511 }
4512 int directRestarts() const {
4513 auto h = Kokkos::create_mirror_view_and_copy(Kokkos::HostSpace(), dir_.stat);
4514 return h(1);
4515 }
4516 void directPivotTolForTest(double t) { dirPivotTol_ = t; }
4517 void directMarkStaleForTest() { facStale_ = true; }
4518
4519 private:
4520};
4521
4522} // namespace peclet::flow
4523
4524#endif // PECLET_FLOW_MAC_CUTCELL_MG_HPP
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 GpOverlayReal< MReal > &ov, int nOv, C3 nn)
int launchDirectFactor(const BottomDirect< FR > &D, int team, double tauPiv0, bool probe=true, bool teamAlgorithm=false)
void applyNeumannGhost(const Level &lv, CCField x, int g=G)
void axpy(CCField y, double a, CCField x)
static int directTeam(const K &k, int team, bool probe, std::size_t scratch=0, int hostTeam=kBottomHostTeam)
double residualNorm(CCField b, CCField x, CCField r, CCField Ap)
const BottomPlanes & directPlanes() const
void applyOutflowGhost(const Level &lv, CCField x, int g=G)
void setStrictPressure(bool on)
Throw instead of reporting when the preconditioner returns a non-finite correction.
bool fusedWrapSmooth(const Level &lv) const
void removeMean(Level &lv, CCField f, bool stopGuard=false)
void scale(CCField y, double a)
void setDebugBreakdown(int which, int iter)
int solvePCGResident(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, int nStar, C3 nnStar)
double readSlot(int k) const
void aypx(CCField y, double a, CCField x)
void fillAxis(Level &lv, CCField f, int axis)
int directSolveTeamForTest(int team, int *Tused=nullptr)
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})
int directApplyForTest(const BottomDirect< FR > &D, CCField z, CCField r, int team, bool noMean=false)
Kokkos::View< double *, Kokkos::HostSpace > kpk_
void matvecOverlap(Level &l0, CCField y, CCField v)
const char * directBottomIneligible() const
void setOutflowCoefficient(bool on)
void setMetric(const double hp[3])
Per-axis cell spacings h_a' = h_a / hRef of the physical domain (doc/anisotropic_metric....
CCField workVector(int slot, std::size_t n, const char *label)
static bool touchesGlobalFace(const Level &lv, int f)
std::vector< C3 > levelRatios() const
The per-level coarsening ratio actually chosen (the level table of §5, gate §8.5).
static C3 parityOg(const Level &lv)
void directPivotTolForTest(double t)
void vcycle(int L, bool sym)
static constexpr double kBrkPAp
Kokkos::View< double, CCMem > slot(int k) const
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)
void applyBoundaryOpenness(Level &lv, Level *fine=nullptr)
void dotTo(Level &lv, CCField a, CCField b, int k)
void setCoarseGhost(bool on)
Neumann coarse ghost on wall/inflow faces before the prolongation (see bcGhost_).
Kokkos::View< long, CCMem > kcnt_
void setAspectThreshold(double theta)
Anisotropic-coarsening aspect threshold theta (see aspectTheta_).
void setRepartition(bool on)
Repartition stages for a weighted level-0 decomposition (see repartition_); before initMpi.
void precondVcycle(CCField zz, CCField rr)
int solveChebyshev(CCField b, CCField x, int maxit, double rtol, int pre, int post, int bottom, double a, double bnd, bool guard3=false)
static constexpr int G
void maskSolid(Level &lv, CCField f)
void applyBoundaryOpennessFrom(Level &lv, const CCField *fo, C3 fext, int fg, C3 fratio)
static constexpr int kNSlots
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 setStopReference(double ref)
void setSlot(int k, double v)
Kokkos::View< double *, CCMem > ks_
void graphAmgSolveBottom(Level &lv)
static C3 mgChooseRatio(const double H[3], const bool canA[3], bool aniso, double theta)
int launchDirectKernel(const BottomDirect< FR > &D, bool precondOnly, int team, bool noMean, CCField zOut=CCField(), CCField rIn=CCField(), bool probe=true)
void estimateEigenvalues(CCConst seed, double &lmin, double &lmax, int iters, int pre, int post, int bottom, EigenStart start=EigenStart::Cold)
void setAgglomerationMode(int mode)
void smooth(Level &lv, int sweeps, bool reverse)
void setAgglomerationExtent(int cells)
static constexpr bool kHostMemory
void fillWrap(Level &lv, CCField f)
void maxabsTo(Level &lv, CCField a, int k)
void saveOutflowPlanes(Level &lv)
void setExactResidual(bool on)
The exact double flux-form level-0 apply in the residual and the Krylov matvec.
void init(int nx, int ny, int nz, int nLevels)
void setEigenWarm(CCField vmax, CCField vmin)
bool caSmooth(const Level &lv) const
int solveFCG(CCField b, CCField x, CCField r, CCField p, CCField z, CCField zp, 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 setMeanRemovalScope(bool all)
static constexpr int kPacket
static constexpr double kBrkRz
double rhsNorm(CCField b, CCField scratch)
void setCommAvoiding(int mask)
Communication-avoiding smoothing mask (kCaMomentum | kCaMg); only kCaMg is read here.
BottomDirect< FR > directFactorForTest(int team, double tauPiv0=kBottomPivotTol, int *Tused=nullptr, bool teamAlgorithm=false)
BottomKernel< FR > makeBottomKernel(bool precondOnly)
flow — directional ghost-cell IBM projection overlay (experimental second staggered IBM).
flow — portable (Kokkos) native per-face domain boundary conditions for the MAC grid.
#define PECLET_FLOW_OMP_SIMD
flow — portable (Kokkos) cut-cell pressure operator + Chorin projection.
flow — the device pressure bottom's direct preconditioner (doc/vof_step_performance_ design....
x
Definition cmp.py:24
dict res
Definition disc_bc.py:230
precond(levels, singular, nu)
constexpr int kBottomHostFactorTeam
double cutcellApplyExactCell(const XV &x, const OV &ox, const OV &oy, const OV &oz, long i, long sx, long sy, long sz, long xp, long xm, long yp, long ym, long zp, long zm, double gfx, double gfy, double gfz)
void mgSaveFacePlane(CCField dst, CCConst src, C3 e, int g, int a, int s)
void residualCutcellFace(CCField r, CCConst x, CCConst b, FPC AC, FPC AFX, FPC AFY, FPC AFZ, C3 e, int g)
void applyCutcellOpFaceWrap(CCField y, CCConst x, FPC AC, FPC AFX, FPC AFY, FPC AFZ, C3 e, C3 n, int g)
void applyCutcellOpFace(CCField y, CCConst x, OpV AC, OpV AFX, OpV AFY, OpV AFZ, C3 e, int g)
void mgRestoreFacePlane(CCField dst, CCConst src, C3 e, int g, int a, int s)
void bcSetOpenness(BField oa, B3 ext, int g, int a, int s, double val)
Definition mac_bc.hpp:438
Kokkos::View< const MReal *, CCMem > FPC
void restrictAvgZeroX(CCField coarse, CCField coarseX, CCConst fine, C3 cext, C3 fext, int gc, int gf, C3 cinner, C3 ratio)
void cutcellResidualFaceCell(const RV &r, const XV &x, const BV &b, const OpV &AC, const OpV &AFX, const OpV &AFY, const OpV &AFZ, long i, long sx, long sy, long sz, long xp, long xm, long yp, long ym, long zp, long zm)
void prolongAdd(CCField fine, CCConst coarse, C3 fext, C3 cext, int gf, int gc, C3 finner, C3 ratio)
constexpr int kBottomHostTeam
void bcZeroPressureGhost(BField phi, B3 ext, int g, int a, int s)
Definition mac_bc.hpp:317
void cutcellSmoothFaceCell(const PV &phi, const BV &b, const OpV &AC, const OpV &AFX, const OpV &AFY, const OpV &AFZ, long i, long sx, long sy, long sz, long xp, long xm, long yp, long ym, long zp, long zm)
void prolongAddCell(const FV &fine, const CV &coarse, C3 fext, C3 cext, int gf, int gc, C3 ratio, int ifx, int ify, int ifz)
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 ccReduce3(const char *name, C3 lo, C3 hi, F f, R &&... reducers)
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 mgCoarsenFacePlane(CCField oc, CCConst of, C3 cext, C3 fext, int gc, int gf, C3 cinner, C3 ratio, int a, int s)
BottomPlanes bottomChoosePlanes(C3 inner, const int bc[6])
void applyCutcellOpExact(CCField y, CCConst x, CCConst ox, CCConst oy, CCConst oz, C3 e, int g, double gfx, double gfy, double gfz)
constexpr int kBottomCap
void gpApplyDelta(CCField y, CCConst x, const GpOverlayReal< Real > &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...
void cutcellResidualCell(const RV &r, const XV &x, const BV &b, const OpV &AC, const OpV &AW, const OpV &AE, const OpV &AS, const OpV &AN, const OpV &AB, const OpV &AT, long i, long xp, long xm, long yp, long ym, long zp, long zm)
constexpr double kBottomTau
void cutcellSmoothColorBoxFace(CCField phi, CCConst b, OpV AC, OpV AFX, OpV AFY, OpV AFZ, C3 e, C3 og, int color, C3 rlo, C3 rhi, C3 slo, C3 shi)
void buildCutcellOpFace(OpV AC, OpV AFX, OpV AFY, OpV AFZ, CCConst ox, CCConst oy, CCConst oz, C3 e, int gb, double gfx, double gfy, double gfz)
void applyCutcellOpExactWrap(CCField y, CCConst x, CCConst ox, CCConst oy, CCConst oz, C3 e, C3 n, int g, double gfx, double gfy, double gfz)
void residualCutcellFaceWrap(CCField r, CCConst x, CCConst b, FPC AC, FPC AFX, FPC AFY, FPC AFZ, C3 e, C3 n, int g)
CcNbrs ccWrapNbrs(int lx, int ly, int lz, C3 n, int g, long i, long sy, long sz)
void cutcellSmoothColorFace(CCField phi, CCConst b, OpV AC, OpV AFX, OpV AFY, OpV AFZ, C3 e, C3 og, int g, int color)
void applyCutcellOpBoxFace(CCField y, CCConst x, OpV AC, OpV AFX, OpV AFY, OpV AFZ, C3 e, C3 rlo, C3 rhi, C3 slo, C3 shi)
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)
Kokkos::View< double *, CCMem > CCField
void meanSubtractCell(const FV &f, const AV &ac, long i, double mean)
constexpr long kBottomMaxCells
void restrictAvg(CCField coarse, CCConst fine, C3 cext, C3 fext, int gc, int gf, C3 cinner, C3 ratio)
constexpr int kBottomMaxComponents
Kokkos::View< MReal *, CCMem > FPV
void coarsenOpenAvgCell(const CV &oxc, const CV &oyc, const CV &ozc, const FV &oxf, const FV &oyf, const FV &ozf, C3 cext, C3 fext, int gc, int gf, C3 ratio, int icx, int icy, int icz)
constexpr double kBottomPivotTol
Kokkos::DefaultExecutionSpace CCExec
void cutcellSmoothColorFaceWrap(CCField phi, CCConst b, FPC AC, FPC AFX, FPC AFY, FPC AFZ, C3 e, C3 n, C3 og, int g, int color)
void applyCutcellOpExactBox(CCField y, CCConst x, CCConst ox, CCConst oy, CCConst oz, C3 e, C3 rlo, C3 rhi, C3 slo, C3 shi, double gfx, double gfy, double gfz)
void residualCutcellBoxFace(CCField r, CCConst x, CCConst b, FPC AC, FPC AFX, FPC AFY, FPC AFZ, C3 e, C3 rlo, C3 rhi, C3 slo, C3 shi)
bool hostRunSerial(long cells)
double cutcellApplyFaceCell(const XV &x, const OpV &AC, const OpV &AFX, const OpV &AFY, const OpV &AFZ, long i, long sx, long sy, long sz, long xp, long xm, long yp, long ym, long zp, long zm)
void bcNeumannGhost(BField f, B3 ext, int g, int a, int s)
Definition mac_bc.hpp:246
void restrictAvgCell(const CV &coarse, const FV &fine, C3 cext, C3 fext, int gc, int gf, C3 ratio, int icx, int icy, int icz)
Kokkos::View< const double *, CCMem > CCConst
void starApplyDelta(CCField y, CCConst x, const StarOverlay &ov, int nOv, C3 nn, C3 extY, int gY, C3 extX, int gX, bool exact)
y += S_star x over the inner cells of the (extY, gY) block, x read from the (extX,...
flow — the multidimensional iteration policies: iteration order follows storage.
flow — Design B of the fluid-only collocated constraint (route 2b): Kron (star-mesh) elimination of s...
static BottomDirect allocate(const BottomPlanes &p, C3 ext)
Kokkos::View< int *, CCMem > stat
static std::size_t scratchBytes(int b)
static double teamMax(const Member &t, int n, const F &f)
static double teamSum(const Member &t, int n, const F &f)
void removeMeanBottom(const Member &t, const CCField &f) const
Kokkos::View< const int *, CCMem > comp
Kokkos::View< int *, CCMem > info
void operator()(const Member &t) const
void applyM(const Member &t, const CCField &R, const CCField &Z, const CCField *keep) const
static int nInner(const BottomLevel &l)
static constexpr bool kHostLane
double dot(const Member &t, const BottomLevel &l, const CCField &a, const CCField &c) const
BottomDirect< FacReal > dir
Kokkos::View< double *, CCMem > ks
Kokkos::View< double *, CCMem > cmean
Kokkos::TeamPolicy< CCExec >::member_type Member
Kokkos::View< const long *, CCMem > ccnt
static long cell(const BottomLevel &l, int q, int &lx, int &ly, int &lz)
static int nExt(const BottomLevel &l)
Kokkos::View< int *, CCMem > kc
Kokkos::View< long *, CCMem > cnt
Kokkos::TeamPolicy< CCExec >::member_type Member
Kokkos::View< double *, CCMem > aug
Kokkos::View< int *, CCMem > nc
void operator()(const Member &t) const
Kokkos::View< int *, CCMem > tmp
long cell(int q, int &lx, int &ly, int &lz) const
Kokkos::View< int *, CCMem > comp
int sweep(const Member &t, const Kokkos::View< int *, CCMem > &src, const Kokkos::View< int *, CCMem > &dst) const
One entry per eliminated solid-centered cell: packed INNER flat index + the apertures of its (up to 6...
static constexpr int N
static constexpr double AC
Kokkos::View< double *, peclet::flow::CCMem > OpV
static constexpr double F
Kokkos::View< float *, IMem > FV
static std::vector< peclet::core::Real > weights(int kind, Grid3 g)
static double mean(const std::vector< double > &a)