flow 1.3.0
Kokkos cut-cell IBM incompressible Navier-Stokes solver + pnm pore extraction
Loading...
Searching...
No Matches
precision_ab.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""WO-M step 2 — what does the solver's fp32 operator storage actually COST us?
3
4The solver stores its multigrid operator (`mac_cutcell_mg.hpp` MReal) and its momentum stencil
5(`flow_ibm.hpp` FV) in float. `-DPECLET_FLOW_OPERATOR_DOUBLE` switches both to double. This script is
6the A/B: run it against a default build and against a `-DPECLET_FLOW_OPERATOR_DOUBLE` build of the same
7commit, and diff the JSON. It measures ACCURACY and COST on cases we actually ship, not on synthetic
8ones, because a precision change that costs 2x operator bandwidth for an accuracy nobody needs is
9not worth shipping.
10
11Cases
12 hydro varRho hydrostatic acid test, ratio 1e1..1e6 (walls +-z): steady max|u|, dP/dz error.
13 vof V2b uniform-velocity identity, ratio 1e1..1e4 (WO-K's decisive gate).
14 porous porous continuity with a PRESCRIBED uniform drag beta: the steady balance
15 f = beta*u is exactly solvable, so this measures the porous path against an exact
16 reference instead of an empirical correlation.
17 contrast the case that started the campaign: a random close packing (phi 0.63), whose order-2
18 marching-squares apertures span ~3 decades. Records the FULL residual trace of every
19 pressure solve (PECLET_FLOW_MG_DEBUG=2) so a floor or a rebound is visible, not just the
20 iteration count. Rule 3b: a capped solve is INVALID, and this reports the cap directly.
21 zh Zick & Homsy sphere-array drag vs the published reference (external ground truth).
22 perm RCP permeability — an actual physical functional, float vs double.
23 cost ms/step and GPU memory on production-sized beds.
24
25Usage:
26 PYTHONPATH=<build> python tests/study/precision_ab.py --cases all --json out.json
27"""
28import argparse
29import json
30import os
31import subprocess
32import sys
33import time
34
35import numpy as np
36
37HERE = os.path.dirname(os.path.abspath(__file__))
38sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "scripts"))
39from _bootstrap import ensure_flow # noqa: E402
40flow = ensure_flow()
41
42
43# --------------------------------------------------------------------------------------------
44# helpers
45
46
48 """Resident GPU memory of THIS process, MiB (0 if no CUDA device / not visible)."""
49 try:
50 out = subprocess.run(
51 ["nvidia-smi", "--query-compute-apps=pid,used_memory", "--format=csv,noheader,nounits"],
52 capture_output=True, text=True, timeout=20).stdout
53 except Exception:
54 return 0.0
55 me = os.getpid()
56 for line in out.splitlines():
57 parts = [p.strip() for p in line.split(",")]
58 if len(parts) == 2 and parts[0].isdigit() and int(parts[0]) == me:
59 return float(parts[1])
60 return 0.0
61
62
63def rcp_sdf(Ng):
64 d = np.load(os.path.join(HERE, "rcp_pack_seed3.npz"))
65 pos, r, side = d["pos"], d["r"], float(d["side"])
66 g = (np.arange(Ng) + 0.5) / Ng * side
67 X, Y, Z = np.meshgrid(g, g, g, indexing="ij")
68 best = np.full((Ng, Ng, Ng), 1e30)
69 for k in range(len(pos)):
70 dd = [None, None, None]
71 for a, c in enumerate((X, Y, Z)):
72 t = c - (pos[k, a] + side / 2)
73 t -= side * np.round(t / side)
74 dd[a] = t
75 best = np.minimum(best, np.sqrt(dd[0] ** 2 + dd[1] ** 2 + dd[2] ** 2) - r[k])
76 return best, side, float(d["phi"])
77
78
79def sc_sphere_sdf(N, phi):
80 """Simple-cubic sphere array at solid fraction phi — the Zick & Homsy geometry, identical to
81 scripts/validate_zick_homsy_sdflow.sc_sdf_xyz so the K it feeds is the recorded one."""
82 R = (phi * 3.0 / (4.0 * np.pi)) ** (1.0 / 3.0) * N
83 g = np.arange(N) + 0.5
84 X, Y, Z = np.meshgrid(g, g, g, indexing="ij")
85 c = N / 2.0
86 return np.sqrt((X - c) ** 2 + (Y - c) ** 2 + (Z - c) ** 2) - R, R
87
88
89# Zick & Homsy (1982) SC drag factor, the table validate_zick_homsy_sdflow.py interpolates.
90ZH_PHI = [0.000125, 0.001, 0.008, 0.027, 0.064, 0.125, 0.216, 0.343, 0.45, 0.5236]
91ZH_K = [1.096, 1.212, 1.525, 2.008, 2.810, 4.292, 7.442, 15.4, 28.1, 42.1]
92
93
94# --------------------------------------------------------------------------------------------
95# cases
96
97
98def case_hydro(ratios=(1e1, 1e2, 1e3, 1e4, 1e5, 1e6), N=8, NZ=24, g=0.1, steps=100):
99 """The hydrostatic acid test: a stratified column at rest must STAY at rest, with the discrete
100 pressure gradient exactly rho_face*g. Any float defect in the momentum diagonal or in the
101 projection coefficient shows up here as a spurious velocity."""
102 out = []
103 for ratio in ratios:
104 s = flow.Solver(N, N, NZ)
105 s.set_rho(1.0)
106 s.set_mu(0.0) # inviscid: the balance is exact
107 s.set_dt(1.0)
108 s.set_domain_bc("-z", "wall", (0, 0, 0))
109 s.set_domain_bc("+z", "wall", (0, 0, 0))
110 s.set_pressure_geometry(np.asfortranarray(np.full((N, N, NZ), 10.0)))
111 rho = np.empty((N, N, NZ), order="F")
112 zs = np.arange(NZ)
113 rho[:, :, :] = np.where(zs < NZ // 2, ratio, 1.0)[None, None, :]
114 s.add_field("rho")
115 s.set_field("rho", rho)
116 s.diagnostics.set_density_mode("variable")
117 s.set_property_model("force_z", "linear", "rho", [0.0, -g])
118 pit = 0
119 for _ in range(steps):
120 s.step()
121 pit = max(pit, s.diagnostics.last_pressure_iterations())
122 umax = max(float(np.abs(s.get_u()).max()), float(np.abs(s.get_v()).max()),
123 float(np.abs(s.get_w()).max()))
124 p = s.get_p()
125 xc, yc = N // 2, N // 2
126 perr = 0.0
127 for z in range(1, NZ):
128 dp = p[xc, yc, z] - p[xc, yc, z - 1]
129 rf = 0.5 * ((ratio if z < NZ // 2 else 1.0) + (ratio if z - 1 < NZ // 2 else 1.0))
130 perr = max(perr, abs(dp + g * rf) / (g * ratio))
131 out.append(dict(ratio=ratio, max_u=umax, dpdz_rel_err=perr, max_pressure_its=pit))
132 print(f" hydro ratio {ratio:8.0e}: max|u| {umax:.3e} dP/dz rel-err {perr:.3e} "
133 f"max its {pit}", flush=True)
134 return out
135
136
137def case_vof(ratios=(1e1, 1e2, 1e3, 1e4), n=32, steps=200):
138 """WO-K's decisive gate: an arbitrary sharp colour field carried by a UNIFORM velocity must
139 leave the velocity unchanged. WO-K measured this floored at ~1.2e-7 by the FLOAT momentum
140 operator (rho_f/dt formed in double in the RHS, stored in float in the stencil) and ~1.2e-15 in
141 a double build. This re-measures it here so the claim is in one table with its cost."""
142 sys.path.insert(0, HERE)
143 import vof_momentum_consistency as vmc # noqa: E402
144 res = vmc.gate_uniform(n=n, steps=steps, scenes=("tilted",), ratios=tuple(ratios))
145 out = []
146 for k, v in (res.items() if isinstance(res, dict) else []):
147 out.append(dict(key=str(k), value=v))
148 return out if out else dict(raw=repr(res))
149
150
151def case_porous(N=16, beta=4.0, f_drive=0.2, eps=0.6, dt=0.5, steps=200):
152 """Porous (volume-averaged) continuity with a PRESCRIBED uniform drag coefficient. At steady
153 state the momentum balance of a uniform periodic bed is exactly f = beta*u_i (interstitial), so
154 the reference is analytic — no closure, no empirical correlation, no dem. This is the porous
155 code path the Ergun fixed-bed benchmark exercises (buildPorousCoeffCons + addDragDiagonal +
156 projectCorrectPorousCons), measured against an exact answer."""
157 s = flow.Solver(N, N, N)
158 s.set_rho(1.0)
159 s.set_mu(1.0)
160 s.set_dt(dt)
161 s.set_advection(False)
162 s.set_pressure_geometry(np.asfortranarray(np.full((N, N, N), 10.0)))
163 s.enable_drag()
164 s.set_porous_continuity(True)
165 s.set_field("eps", np.asfortranarray(np.full((N, N, N), eps)))
166 s.set_field("drag_beta", np.asfortranarray(np.full((N, N, N), beta)))
167 s.diagnostics.exchange_field("eps")
168 s.diagnostics.exchange_field("drag_beta")
169 s.sync_porous_prev()
170 s.set_body_force((0.0, 0.0, f_drive))
171 pit = 0
172 for _ in range(steps):
173 s.step()
174 pit = max(pit, s.diagnostics.last_pressure_iterations())
175 ui = float(s.get_w().mean())
176 # eps-conservative pair: the momentum diagonal is eps*rho/dt + beta and the steady drive is the
177 # body force f, so f = beta*u_i exactly.
178 ref = f_drive / beta
179 rel = abs(ui - ref) / abs(ref)
180 print(f" porous: u_i {ui:.10e} exact {ref:.10e} rel-err {rel:.3e} max its {pit}", flush=True)
181 return dict(u_interstitial=ui, exact=ref, rel_err=rel, max_pressure_its=pit,
182 porous_residual=float(s.max_porous_residual()))
183
184
185def case_contrast(Ngs=(48, 64, 96), steps=4, maxit=300, rtol=1e-8, driver="pcg"):
186 """THE campaign case: a random close packing whose order-2 (marching-squares) apertures span
187 about three decades, which is where the collocated session measured float PCG floor at 8e-7 and
188 REBOUND to 3.8e-5 while a double build converged monotonically to 1e-8.
189
190 Reports, per solve: iterations, whether the cap was touched (rule 3b: a capped solve is INVALID,
191 not degraded), the minimum r/r0 reached and the final r/r0 — a floor shows up as
192 min == final >> rtol and a rebound as final > min."""
193 out = []
194 for Ng in Ngs:
195 sdf, side, phi = rcp_sdf(Ng)
196 lv = max(2, int(np.log2(Ng)) - 1)
197 s = flow.Solver(Ng, Ng, Ng)
198 s.set_rho(1.0)
199 s.set_mu(0.1)
200 s.set_dt(80.0)
201 s.set_body_force((1e-3, 0, 0))
202 s.set_advection(False)
203 s.diagnostics.set_velocity_solver_params(150)
204 s.set_pressure_multigrid(True, levels=lv)
205 if driver == "pcg":
206 s.set_pressure_pcg(True, maxit, rtol)
207 elif driver == "fcg":
208 s.set_pressure_fcg(True, maxit, rtol)
209 else:
210 s.set_pressure_chebyshev(True, maxit, rtol)
211 s.set_solid(np.asfortranarray(sdf), cutcell_pressure=True)
212 its, t0 = [], time.time()
213 for _ in range(steps):
214 s.step()
215 its.append(int(s.diagnostics.last_pressure_iterations()))
216 dt_ = time.time() - t0
217 rec = dict(Ng=Ng, phi=phi, levels=lv, driver=driver, iters=its, cap=maxit,
218 capped=bool(max(its) >= maxit), div=float(s.max_open_divergence()),
219 umean=float(s.get_u().mean()), s_per_step=dt_ / steps)
220 out.append(rec)
221 print(f" contrast Ng={Ng} ({driver}): its {its} cap={maxit} "
222 f"{'CAPPED(INVALID)' if rec['capped'] else 'ok'} div {rec['div']:.2e} "
223 f"{rec['s_per_step']:.2f} s/step", flush=True)
224 return out
225
226
227def case_zh(Ns=(32, 48, 64), phi=0.125, mu=0.1, F=1e-3, dt=None, max_steps=600, tol=1e-6):
228 """Zick & Homsy simple-cubic sphere-array drag — the solver's external ground truth."""
229 Kref = float(np.interp(phi, ZH_PHI, ZH_K))
230 out = []
231 for N in Ns:
232 sdf, R = sc_sphere_sdf(N, phi)
233 s = flow.Solver(N, N, N)
234 s.set_rho(1.0)
235 s.set_mu(mu)
236 s.set_dt(dt if dt else 60.0)
237 s.set_body_force((F, 0, 0))
238 s.set_advection(False)
239 s.diagnostics.set_velocity_solver_params(120)
240 s.set_pressure_multigrid(True, levels=max(2, int(np.log2(N)) - 1))
241 s.set_pressure_pcg(True, 400, 1e-9)
242 s.set_solid(np.asfortranarray(sdf), cutcell_pressure=True)
243 prev, nit = 0.0, 0
244 for it in range(max_steps):
245 s.step()
246 nit = it + 1
247 if it % 5 == 4:
248 m = float(s.get_u().mean())
249 if it > 10 and abs(m - prev) < tol * (abs(m) + 1e-30):
250 break
251 prev = m
252 umean = float(s.get_u().mean())
253 # K = F R^2 / (6 pi mu a U) form used by validate_zick_homsy_sdflow.drag_K
254 K = F * (N ** 3) / (6.0 * np.pi * mu * R * umean) if umean else float("nan")
255 out.append(dict(N=N, phi=phi, umean=umean, K=K, Kref=Kref,
256 rel_vs_ref=(K / Kref - 1.0) if Kref else float("nan"), steps=nit,
257 pit=int(s.diagnostics.last_pressure_iterations()),
258 div=float(s.max_open_divergence())))
259 print(f" zh N={N}: K {K:.6f} (ref {Kref:.3f}, {100*(K/Kref-1):+.2f}%) "
260 f"umean {umean:.12e} steps {nit} "
261 f"its {out[-1]['pit']} div {out[-1]['div']:.2e}", flush=True)
262 return out
263
264
265def case_perm(Ngs=(44, 56), mu=0.1, F=1e-3, dt=80.0, max_steps=3000, tol=1e-6):
266 """The RCP permeability — a physical functional on the high-contrast geometry."""
267 out = []
268 for Ng in Ngs:
269 sdf, side, phi = rcp_sdf(Ng)
270 lv = max(2, int(np.log2(Ng)) - 1)
271 s = flow.Solver(Ng, Ng, Ng)
272 s.set_rho(1.0)
273 s.set_mu(mu)
274 s.set_dt(dt)
275 s.set_body_force((F, 0, 0))
276 s.set_advection(False)
277 s.diagnostics.set_velocity_solver_params(150)
278 s.set_pressure_multigrid(True, levels=lv)
279 s.set_pressure_pcg(True, 400, 1e-9)
280 s.set_solid(np.asfortranarray(sdf), cutcell_pressure=True)
281 prev, nit = 0.0, 0
282 for it in range(max_steps):
283 s.step()
284 nit = it + 1
285 if it % 5 == 4:
286 m = float(s.get_u().mean())
287 if it > 10 and abs(m - prev) < tol * (abs(m) + 1e-30):
288 break
289 prev = m
290 umean = float(s.get_u().mean())
291 k = mu * umean / F * (side / Ng) ** 2
292 out.append(dict(Ng=Ng, phi=phi, k=k, steps=nit, pit=int(s.diagnostics.last_pressure_iterations()),
293 capped=bool(s.diagnostics.last_pressure_iterations() >= 400),
294 div=float(s.max_open_divergence())))
295 print(f" perm Ng={Ng}: k {k:.8e} steps {nit} its {out[-1]['pit']}"
296 f"{' CAPPED(INVALID)' if out[-1]['capped'] else ''}", flush=True)
297 return out
298
299
300def case_cost(Ngs=(64, 96, 128), steps=10, warmup=3):
301 """ms/step and GPU memory on production-sized high-contrast beds. Operator storage is what the
302 precision switch changes, so this is where the +28 B/cell of a full fp64 hierarchy shows up."""
303 out = []
304 for Ng in Ngs:
305 m0 = gpu_mem_mb()
306 sdf, side, phi = rcp_sdf(Ng)
307 lv = max(2, int(np.log2(Ng)) - 1)
308 s = flow.Solver(Ng, Ng, Ng)
309 s.set_rho(1.0)
310 s.set_mu(0.1)
311 s.set_dt(80.0)
312 s.set_body_force((1e-3, 0, 0))
313 s.set_advection(False)
314 s.diagnostics.set_velocity_solver_params(150)
315 s.set_pressure_multigrid(True, levels=lv)
316 s.set_pressure_pcg(True, 300, 1e-6) # 1e-8 CAPS on this bed in float (see case_contrast)
317 s.set_solid(np.asfortranarray(sdf), cutcell_pressure=True)
318 for _ in range(warmup):
319 s.step()
320 m1 = gpu_mem_mb()
321 t0 = time.time()
322 its = []
323 for _ in range(steps):
324 s.step()
325 its.append(int(s.diagnostics.last_pressure_iterations()))
326 dt_ = time.time() - t0
327 cells = Ng ** 3
328 rec = dict(Ng=Ng, cells=cells, levels=lv, ms_per_step=1e3 * dt_ / steps,
329 median_its=float(np.median(its)),
330 ms_per_iter=1e3 * dt_ / steps / max(1.0, float(np.median(its))),
331 capped=bool(max(its) >= 300),
332 gpu_mem_mb=m1, gpu_mem_delta_mb=m1 - m0,
333 bytes_per_cell=(m1 - m0) * 1024 * 1024 / cells if m1 > m0 else float("nan"))
334 out.append(rec)
335 print(f" cost Ng={Ng}: {rec['ms_per_step']:.1f} ms/step med its {rec['median_its']:.0f}"
336 f" {rec['ms_per_iter']:.2f} ms/it gpu {m1:.0f} MiB "
337 f"({rec['bytes_per_cell']:.0f} B/cell)", flush=True)
338 return out
339
340
341def case_floor(Ng=96, steps=2, maxit=500, driver="pcg", levels=None):
342 """ATTAINABLE-ACCURACY FLOOR. Drive the solve with an unreachable tolerance (rtol 1e-14) and let
343 it run to `maxit`: the residual stops descending where round-off in the CG recurrence takes over,
344 and that plateau — not the iteration count — is what a precision policy can and cannot move.
345
346 Read with the classical bound: CG's attainable relative residual is O(eps * kappa(A)). So the
347 ratio of the float floor to the double floor should be ~ eps_f32/eps_f64 ~ 5e8 IF storage
348 precision is the binding term, and the double floor itself measures eps_f64 * kappa, i.e. the
349 conditioning of the operator — a limit no storage policy can cross. Depth-independence
350 discriminates the two: a storage/coarsening defect worsens with more levels, a conditioning
351 limit does not (pass `levels` to check).
352
353 Run under PECLET_FLOW_MG_DEBUG=2 and read the [mg] trace; this only sets the configuration up.
354 """
355 return case_trace(Ng=Ng, steps=steps, maxit=maxit, rtol=1e-14, driver=driver, levels=levels)
356
357
358def case_trace(Ng=96, steps=2, maxit=300, rtol=1e-8, driver="pcg", levels=None):
359 """One high-contrast pressure solve with the operator's own convergence trace. Run under
360 PECLET_FLOW_MG_DEBUG=2 PECLET_FLOW_MG_DEBUG_SOLVES=<n> and read the `[mg] it ... r/r0` lines:
361 a FLOOR is min == final >> rtol, a REBOUND is final > min. That distinction is the whole
362 difference between 'the preconditioner is weak' and 'the operator's A*1=0 defect has stopped
363 the residual from going anywhere', and an iteration count cannot show it."""
364 sdf, side, phi = rcp_sdf(Ng)
365 lv = levels if levels else max(2, int(np.log2(Ng)) - 1)
366 s = flow.Solver(Ng, Ng, Ng)
367 s.set_rho(1.0)
368 s.set_mu(0.1)
369 s.set_dt(80.0)
370 s.set_body_force((1e-3, 0, 0))
371 s.set_advection(False)
372 s.diagnostics.set_velocity_solver_params(150)
373 s.set_pressure_multigrid(True, levels=lv)
374 if driver == "pcg":
375 s.set_pressure_pcg(True, maxit, rtol)
376 elif driver == "fcg":
377 s.set_pressure_fcg(True, maxit, rtol)
378 else:
379 s.set_pressure_chebyshev(True, maxit, rtol)
380 s.set_solid(np.asfortranarray(sdf), cutcell_pressure=True)
381 for _ in range(steps):
382 s.step()
383 return dict(Ng=Ng, levels=lv, driver=driver, its=int(s.diagnostics.last_pressure_iterations()),
384 cap=maxit, div=float(s.max_open_divergence()))
385
386
387CASES = dict(hydro=case_hydro, trace=case_trace, floor=case_floor, vof=case_vof, porous=case_porous, contrast=case_contrast,
388 zh=case_zh, perm=case_perm, cost=case_cost)
389
390
391def main():
392 ap = argparse.ArgumentParser()
393 ap.add_argument("--cases", default="hydro,porous,vof,contrast,zh,perm,cost")
394 ap.add_argument("--json", default="")
395 ap.add_argument("--label", default="")
396 args = ap.parse_args()
397 names = list(CASES) if args.cases == "all" else args.cases.split(",")
398 res = dict(label=args.label, build=flow.__file__)
399 for nm in names:
400 print(f"[{nm}]", flush=True)
401 res[nm] = CASES[nm]()
402 if args.json:
403 with open(args.json, "w") as f:
404 json.dump(res, f, indent=1)
405 print(f"wrote {args.json}")
406
407
408if __name__ == "__main__":
409 main()
case_zh(Ns=(32, 48, 64), phi=0.125, mu=0.1, F=1e-3, dt=None, max_steps=600, tol=1e-6)
case_trace(Ng=96, steps=2, maxit=300, rtol=1e-8, driver="pcg", levels=None)
case_porous(N=16, beta=4.0, f_drive=0.2, eps=0.6, dt=0.5, steps=200)
case_vof(ratios=(1e1, 1e2, 1e3, 1e4), n=32, steps=200)
sc_sphere_sdf(N, phi)
case_perm(Ngs=(44, 56), mu=0.1, F=1e-3, dt=80.0, max_steps=3000, tol=1e-6)
case_cost(Ngs=(64, 96, 128), steps=10, warmup=3)
case_floor(Ng=96, steps=2, maxit=500, driver="pcg", levels=None)
case_contrast(Ngs=(48, 64, 96), steps=4, maxit=300, rtol=1e-8, driver="pcg")
case_hydro(ratios=(1e1, 1e2, 1e3, 1e4, 1e5, 1e6), N=8, NZ=24, g=0.1, steps=100)
static double mean(const std::vector< double > &a)