flow 1.3.0
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
test_vardensity_mpi.cpp
Go to the documentation of this file.
1// flow — multi-rank validation of the VARIABLE-DENSITY projection (VoF rung V-1, WO-A).
2//
3// `doc/variable_density_projection.md` §4 listed "MPI/CUDA validation deferred": the rho bridge to
4// the g=1 MG block (`copyBlockShifted`, offset G-1), the `fillPropGhosts` ghost ring, and the
5// Chebyshev bound estimation were *believed* MPI-ready. This test gates them.
6//
7// Two configurations on a 16x16x32 grid — the aligned ORB cuts the LONG z axis at np = 2, 4, which
8// is deliberately the axis both configurations load (WO-F: the walled axis must be CUT):
9//
10// * `walls-z` — the WO-A hydrostatic acid test, the multi-rank twin of
11// `tests/kokkos/test_vardensity_projection.cpp`: two layers at density ratio 1000 at rest
12// between no-slip walls at +-z under the gravity closure force_z = -g*rho. From rest the face
13// force -g*rho_f divided by the face inertia rho_f/dt gives a UNIFORM w* = -g*dt, so the
14// interior divergence vanishes, the projection returns u == 0, and P accumulates exactly
15// -rho_f*g per face. A stale rho ghost, a rho bridge that misses the g=1 ghost ring, or a
16// mismatched face mean breaks the telescoping and leaves a PERMANENT spurious velocity. Both
17// gates are decomposition-independent physics: max|u| ~ 1e-16 and dP/dz == -g*rho_f at every
18// np. The pressure gate is the sharper one — see the walled-axis note below.
19//
20// * `jump-z` — the rho-ghost / rho-bridge canary: a SHARP ratio-1000 density jump stacked along
21// the CUT axis, so at np = 2 and 4 the jump sits exactly on a rank boundary and the coefficient
22// rho0/rho_f at that face is assembled from an exchanged ghost. Fully periodic, driven by a
23// uniform body force (no rest state — this one is a pure np-consistency + Chebyshev-count
24// gate).
25//
26// WALLED AXIS / MPI — this is the point of the configuration (WO-F). flow's per-face domain BCs
27// used to be imposed by EVERY rank on its OWN block faces: `applyVelocityBcCompTo`, the flux-
28// openness construction and the MG's per-level boundary openness had no `touchesGlobalFace`
29// ownership test, unlike the scalar BCs (`applyScalarBc`). A partition cutting a walled axis then
30// split the domain into independent walled sub-columns — each separately hydrostatic, so the
31// VELOCITY canary still read ~1e-17 and only the PRESSURE revealed it (WO-A measured on this grid
32// with z cut: max|u| = 4.5e-17 but max|P_dist - P_ref| = 4.0e+02 and dP/dz off by 8x g*rho). WO-A's
33// tests therefore kept the walled axis uncut and asserted it; WO-F fixed the ownership test, so
34// this test now REQUIRES the walled axis to be cut (asserted below) and gates the PRESSURE field
35// against the single-rank reference, which is the only thing that sees the defect.
36//
37// Comparison protocol (the `tests/kokkos_mpi` pattern): the distributed run is compared POINTWISE
38// against a full-grid single-rank reference built on rank 0 with the identical configuration.
39// np = 1 is bit-exact; np > 1 lands on the MPI reduction-order floor — Chebyshev's bound
40// estimation (`dot`) and `removeMean` both go through an MPI_SUM allreduce whose summation order is
41// a function of the rank count, so np > 1 cannot be bitwise by construction. The Chebyshev V-cycle
42// COUNT per step is reported and gated at +-1 (the same knife-edge on the `maxabs(r) < rtol*r0`
43// stopping test is reproducible at np = 1 by changing only the OpenMP thread count).
44#include <mpi.h>
45
46#include <cmath>
47#include <cstdio>
48#include <cstring>
49#include <Kokkos_Core.hpp>
50#include <string>
51#include <vector>
52
53#include "flow_ibm.hpp"
54#include "peclet/core/common/types.hpp"
55#include "peclet/core/decomp/block_decomposer.hpp"
56
57using peclet::core::IVec;
59
60static constexpr int NX = 16, NY = 16, NZ = 32, STEPS = 20;
61static constexpr double RATIO = 1000.0, GRAV = 0.1, DT = 1.0, RHO0 = 1.0, FX = 1e-3;
62static constexpr std::size_t GCELLS = (std::size_t)NX * NY * NZ;
63
64struct Config {
65 const char* name;
66 int axis; // density-layering axis (z for both: the walled column AND the cut-axis jump)
67 bool walls; // hydrostatic acid test (else the periodic body-force consistency probe)
68};
69
70static double rhoAt(const Config& c, int x, int y, int z) {
71 const int q[3] = {x, y, z}, n[3] = {NX, NY, NZ};
72 return (q[c.axis] < n[c.axis] / 2) ? RATIO : 1.0; // heavy first half
73}
74
75template <class Fn>
76static std::vector<double> blockOf(Fn f, int ox, int oy, int oz, int lnx, int lny, int lnz) {
77 std::vector<double> v((std::size_t)lnx * lny * lnz);
78 for (int z = 0; z < lnz; ++z)
79 for (int y = 0; y < lny; ++y)
80 for (int x = 0; x < lnx; ++x)
81 v[(std::size_t)x + (std::size_t)y * lnx + (std::size_t)z * lnx * lny] =
82 f(x + ox, y + oy, z + oz);
83 return v;
84}
85
86static void configure(IbmSolver& s, const Config& c, int ox, int oy, int oz, int lnx, int lny,
87 int lnz) {
88 s.setRho(RHO0);
89 s.setMu(c.walls ? 0.0 : 0.1); // hydrostatic: inviscid, so the balance is exact
90 s.setDt(DT);
91 s.setAdvection(false);
92 if (c.walls) {
93 s.setDomainBc(2 * c.axis + 0, 1, 0, 0, 0);
94 s.setDomainBc(2 * c.axis + 1, 1, 0, 0, 0);
95 } else {
96 s.setBodyForce(FX, 0, 0);
97 }
98 s.setPressureGeometry(std::vector<double>((std::size_t)lnx * lny * lnz, 10.0));
99 s.setDensityMode(true); // installs the Chebyshev pressure driver by default
100 s.setField("rho", blockOf([&](int x, int y, int z) { return rhoAt(c, x, y, z); }, ox, oy, oz, lnx,
101 lny, lnz));
102 s.exchangeField("rho");
103 if (c.walls) {
104 // gravity closure: the per-cell force -g*rho, face-interpolated to -g*rho_f
105 s.setPropertyModel("force_z", peclet::flow::ClosureKind::LinearMix, "rho", "", {0.0, -GRAV});
106 // WO-A/WO-F used to follow this with an explicit `setField("force_z", …) + exchangeField` seed
107 // of the SAME values, whose only purpose was to fill the force field's GHOST ring by hand:
108 // `applyClosure` writes the inner cells only and nothing in `step()` exchanged a `force_*`
109 // field, so `buildRhsVar`'s `0.5*(fb(i) + fb(i-strd))` halved the face force on the first inner
110 // plane of every block. That was a third, independent pre-existing defect, out of WO-F's scope.
111 // WO-G FIXED it (`fillCellForceGhosts` at the top of `step()`), so the hand seed is gone and
112 // this configuration is once again a pure closure-path test. Note the seed and the fix do NOT
113 // agree at the WALL: the seed's exchange periodic-wraps (bringing the light layer's -g*1 to the
114 // heavy wall), the fix applies the Neumann copy rho itself gets (-g*RATIO), which is the value
115 // that keeps f_f/rho_f == -g there. Both are invisible in the result because the wall-normal
116 // velocity on that plane is pinned by the Dirichlet BC — see `test_bodyforce_ghost_mpi.cpp`.
117 }
118}
119
120// Gather per-rank inner blocks (x-fastest) into the global field on rank 0.
121static std::vector<double> gatherGlobal(const std::vector<double>& local, int ox, int oy, int oz,
122 int lnx, int lny, int lnz, int rank, int size) {
123 std::vector<double> global;
124 if (rank == 0)
125 global.assign(GCELLS, 0.0);
126 for (int r = 0; r < size; ++r) {
127 int meta[6] = {ox, oy, oz, lnx, lny, lnz};
128 if (r == 0) {
129 if (rank == 0)
130 for (int z = 0; z < lnz; ++z)
131 for (int y = 0; y < lny; ++y)
132 std::memcpy(&global[(std::size_t)ox + (std::size_t)(y + oy) * NX +
133 (std::size_t)(z + oz) * NX * NY],
134 &local[(std::size_t)y * lnx + (std::size_t)z * lnx * lny],
135 (std::size_t)lnx * sizeof(double));
136 continue;
137 }
138 if (rank == r) {
139 MPI_Send(meta, 6, MPI_INT, 0, 100 + r, MPI_COMM_WORLD);
140 MPI_Send(local.data(), (int)local.size(), MPI_DOUBLE, 0, 200 + r, MPI_COMM_WORLD);
141 } else if (rank == 0) {
143 std::vector<double> buf((std::size_t)meta[3] * meta[4] * meta[5]);
144 MPI_Recv(buf.data(), (int)buf.size(), MPI_DOUBLE, r, 200 + r, MPI_COMM_WORLD,
146 for (int z = 0; z < meta[5]; ++z)
147 for (int y = 0; y < meta[4]; ++y)
148 std::memcpy(&global[(std::size_t)meta[0] + (std::size_t)(y + meta[1]) * NX +
149 (std::size_t)(z + meta[2]) * NX * NY],
150 &buf[(std::size_t)y * meta[3] + (std::size_t)z * meta[3] * meta[4]],
151 (std::size_t)meta[3] * sizeof(double));
152 }
153 }
154 return global;
155}
156
157static double maxAbsDiff(const std::vector<double>& a, const std::vector<double>& b) {
158 // WO-R2: NaN-PROPAGATING. `std::fmax(m, NaN) == m`, so the obvious loop returns 0.000e+00 for a
159 // field that has gone entirely NaN and every bitwise gate built on it passes (WO-R found this on
160 // a drained open-boundary run). A non-finite difference must fail, so return it.
161 double m = 0;
162 for (std::size_t i = 0; i < b.size(); ++i) {
163 const double d = std::fabs(a[i] - b[i]);
164 if (!(d == d))
165 return d; // NaN
166 m = std::fmax(m, d);
167 }
168 return m;
169}
170static double maxAbs(const std::vector<double>& a) {
171 double m = 0;
172 for (double v : a)
173 m = std::fmax(m, std::fabs(v));
174 return m;
175}
176
177// Discrete hydrostatic gradient along the walled column (x = NX/2, y = NY/2).
178static double pressureGradientError(const std::vector<double>& p) {
179 double perr = 0;
180 for (int z = 1; z < NZ; ++z) {
181 const std::size_t i0 =
182 (std::size_t)(NX / 2) + (std::size_t)(NY / 2) * NX + (std::size_t)(z - 1) * NX * NY;
183 const std::size_t i1 = i0 + (std::size_t)NX * NY;
184 const double rf = 0.5 * (((z < NZ / 2) ? RATIO : 1.0) + ((z - 1 < NZ / 2) ? RATIO : 1.0));
185 perr = std::fmax(perr, std::fabs((p[i1] - p[i0]) + GRAV * rf) / (GRAV * RATIO));
186 }
187 return perr;
188}
189
190int main(int argc, char** argv) {
191 MPI_Init(&argc, &argv);
192 Kokkos::initialize(argc, argv);
193 int fail = 0;
194 {
195 int rank = 0, size = 1;
198
199 auto dec = peclet::flow::CutcellMG::decomposition(static_cast<std::size_t>(size), NX, NY, NZ);
200 auto blk = dec.block(rank);
201 const int ox = (int)blk.origin[0], oy = (int)blk.origin[1], oz = (int)blk.origin[2];
202 const int lnx = (int)blk.size[0], lny = (int)blk.size[1], lnz = (int)blk.size[2];
203 const int gn[3] = {NX, NY, NZ};
204 bool cut[3] = {false, false, false};
205 for (const auto& sz : dec.sizes())
206 for (int a = 0; a < 3; ++a)
207 if ((int)sz[a] != gn[a])
208 cut[a] = true;
209 if (rank == 0)
210 std::printf("VARDENSITY MPI np=%d grid %dx%dx%d block %dx%dx%d cut axes: %s%s%s\n", size,
211 NX, NY, NZ, lnx, lny, lnz, cut[0] ? "x" : "", cut[1] ? "y" : "",
212 cut[2] ? "z" : "");
213
214 const Config configs[] = {{"walls-z", 2, true}, {"jump-z", 2, false}};
215
216 for (const Config& c : configs) {
217 // WO-F: the walled axis must be CUT — that is the case the domain-BC ownership test exists
218 // for. If a future decomposition change stops cutting z, this test would silently stop
219 // covering it, so fail loudly instead.
220 if (size > 1 && !cut[c.axis]) {
221 if (rank == 0)
222 std::printf(
223 " [%-7s np=%d] FAIL — the decomposition does NOT cut axis %d; this test "
224 "exists to gate the domain-BC / property-ghost rank ownership on a CUT "
225 "walled axis (WO-F)\n",
226 c.name, size, c.axis);
227 fail = 1;
228 continue;
229 }
230 // --- distributed ---
232 sd.initMpi(dec, MPI_COMM_WORLD);
233 configure(sd, c, ox, oy, oz, lnx, lny, lnz);
234 std::vector<long> itd;
235 for (int it = 0; it < STEPS; ++it) {
236 sd.step();
237 itd.push_back(sd.lastPressureIterations());
238 }
239
240 std::vector<double> gu[3];
241 for (int comp = 0; comp < 3; ++comp)
242 gu[comp] = gatherGlobal(sd.getVelocity(comp), ox, oy, oz, lnx, lny, lnz, rank, size);
243 const std::vector<double> gp =
244 gatherGlobal(sd.getPressure(), ox, oy, oz, lnx, lny, lnz, rank, size);
245
246 // --- single-rank full-grid reference on rank 0 ---
247 if (rank == 0) {
248 IbmSolver ref(NX, NY, NZ);
249 configure(ref, c, 0, 0, 0, NX, NY, NZ);
250 std::vector<long> itr;
251 std::vector<double> refUmax;
252 for (int it = 0; it < STEPS; ++it) {
253 ref.step();
254 itr.push_back(ref.lastPressureIterations());
255 refUmax.push_back(
256 std::fmax(maxAbs(ref.getVelocity(0)),
257 std::fmax(maxAbs(ref.getVelocity(1)), maxAbs(ref.getVelocity(2)))));
258 }
259 double du = 0, umag = 0;
260 for (int comp = 0; comp < 3; ++comp) {
261 du = std::fmax(du, maxAbsDiff(gu[comp], ref.getVelocity(comp)));
262 umag = std::fmax(umag, maxAbs(ref.getVelocity(comp)));
263 }
264 const double dp = maxAbsDiff(gp, ref.getPressure());
265 const double pmag = maxAbs(ref.getPressure());
266 // np=1 bit-exact; np>1 the MPI reduction-order floor (Chebyshev bounds + removeMean).
267 const double utol = (size == 1) ? 0.0 : std::fmax(1e-15, 1e-11 * umag);
268 const double ptol = (size == 1) ? 0.0 : std::fmax(1e-12, 1e-11 * pmag);
269 // Chebyshev V-cycle count vs the decomposition — over the NON-DEGENERATE steps only. Once
270 // the hydrostatic state has reached machine zero the solve's own r0 is round-off noise and
271 // the `maxabs(r) < rtol*r0` stopping test is a knife edge: at FIXED np=1 the count sequence
272 // already changes with the OpenMP thread count alone (measured: steps 7.. read
273 // 15,13,13,... at 1 thread, 16,13,14,... at 2, 16,14,14,13,... at 8). Gating those steps
274 // would gate round-off, not the bounds path. The window predicate is the reference's own
275 // velocity magnitude entering the step.
276 long dits = 0;
277 int nWin = 0;
278 for (std::size_t k = 0; k < itr.size(); ++k) {
279 if (k > 0 && refUmax[k - 1] <= 1e-12)
280 continue;
281 ++nWin;
282 dits = std::max(dits, std::labs(itd[k] - itr[k]));
283 }
284 // np=1 must be decomposition-independent EXACTLY. np>1 gets one V-cycle of slack, for the
285 // same reason utol and ptol get a floor there: the stopping test is
286 // `maxabs(r) < rtol*r0`, and at np>1 both sides of that come from a global reduction whose
287 // summation order is not the single-rank order. The count is a DISCONTINUOUS function of a
288 // quantity that already carries a reduction-order floor, so a step whose residual sits near
289 // the threshold can land on either side of it and spend one V-cycle more or less. That is
290 // not the bounds path differing; the answers agree far inside utol/ptol when it happens.
291 // Widened from 0 on 2026-09-12, when double operator storage became the default
292 // (SCALING_ISSUES #1) and [walls-z np=4] began reporting max-delta=1 with du=4.4e-17
293 // against a 1.0e-15 tolerance, max|u|=2.8e-17 against 1e-14 and dP/dz err=1.1e-15 against
294 // 1e-11 — every physics gate passing by three orders of magnitude or more. Exact equality
295 // was never well posed at np>1: this test's own comment above records that the count
296 // sequence changes with the OpenMP THREAD COUNT alone at fixed np=1. One V-cycle is the
297 // smallest slack that is not zero, and it keeps the assertion sharp — a real decomposition
298 // defect shows up as a large and growing divergence, never as a steady +/-1.
299 const long itTol = (size == 1) ? 0 : 1;
300 bool ok = du <= utol && dp <= ptol && dits <= itTol;
301 double umax = -1, perr = -1;
302 if (c.walls) { // decomposition-independent physics gates
303 umax = std::fmax(maxAbs(gu[0]), std::fmax(maxAbs(gu[1]), maxAbs(gu[2])));
305 ok = ok && umax < 1e-14 && perr < 1e-11;
306 }
307 char extra[80] = "";
308 if (c.walls)
309 std::snprintf(extra, sizeof(extra), " | max|u|=%.2e dP/dz err=%.2e", umax, perr);
310 std::printf(
311 " [%-7s np=%d] du=%.3e dp=%.3e (tol %.1e/%.1e) | cheb its %ld..%ld "
312 "max-delta=%ld over %d/%d non-degenerate steps%s %s\n",
313 c.name, size, du, dp, utol, ptol, itr.front(), itr.back(), dits, nWin, STEPS, extra,
314 ok ? "OK" : "FAIL");
315 std::printf(" dist its:");
316 for (long v : itd)
317 std::printf(" %ld", v);
318 std::printf("\n ref its:");
319 for (long v : itr)
320 std::printf(" %ld", v);
321 std::printf("\n");
322 if (!ok)
323 fail = 1;
324 }
326 }
327 if (rank == 0)
328 std::printf("VARDENSITY MPI (np=%d): %s\n", size, fail ? "FAIL" : "PASS");
329 }
330 Kokkos::finalize();
331 MPI_Finalize();
332 return fail;
333}
void setDomainBc(int face, int type, double vx, double vy, double vz)
void exchangeField(const std::string &name)
void setAdvection(bool on)
void setPropertyModel(const std::string &target, ClosureKind kind, const std::string &in0, const std::string &in1, const std::vector< double > &params)
void setPressureGeometry(const std::vector< double > &sdfInner)
void setField(const std::string &name, const std::vector< double > &v)
void setBodyForce(double fx, double fy, double fz)
Body force per unit volume, in the caller's units (e.g. a mean pressure gradient, or rho*g).
std::vector< double > getVelocity(int c)
void setDensityMode(bool variable)
void setMu(double m)
Dynamic viscosity, in the caller's units.
void setRho(double r)
Fluid density, in the caller's units.
std::vector< double > getPressure()
int main()
Definition dbg_scene.cpp:13
flow — host-facing Kokkos IBM Navier-Stokes solver (drop-in flow-style API).
const char * name
static double maxAbs(const std::vector< double > &a)
static constexpr int NY
static constexpr double GRAV
static std::vector< double > blockOf(Fn f, int ox, int oy, int oz, int lnx, int lny, int lnz)
static constexpr double FX
static constexpr double RHO0
static double rhoAt(const Config &c, int x, int y, int z)
static std::vector< double > gatherGlobal(const std::vector< double > &local, int ox, int oy, int oz, int lnx, int lny, int lnz, int rank, int size)
static constexpr int STEPS
static double maxAbsDiff(const std::vector< double > &a, const std::vector< double > &b)
static constexpr double DT
static double pressureGradientError(const std::vector< double > &p)
static constexpr double RATIO
static constexpr int NX
static constexpr std::size_t GCELLS
static constexpr int NZ
static void configure(IbmSolver &s, const Config &c, int ox, int oy, int oz, int lnx, int lny, int lnz)