Skip to content

amr: block-structured multi-level AMR (Body 2) — recursive nesting, subcycling, np≥2 - #6

Merged
sbryngelson merged 41 commits into
up/megafrom
amr-multilevel
Jul 13, 2026
Merged

amr: block-structured multi-level AMR (Body 2) — recursive nesting, subcycling, np≥2#6
sbryngelson merged 41 commits into
up/megafrom
amr-multilevel

Conversation

@sbryngelson

Copy link
Copy Markdown
Owner

Body 2 of the block-structured AMR family: generalizes the single-level AMR on up/mega (MFlowCode#1628, Body 1) to arbitrary refinement depth (L0…L_amr_max_level) with recursive Berger–Colella coupling. Stacked on up/mega — merge after Body 1. Draft.

30 commits, built incrementally; each validated (CPU reldebug + GPU/V100) as it landed.

Increments

  • 1 — param + gate: amr_max_level namelist param (default 1 = current behavior); amr_max_level>1 fail-closed in m_checker; design roadmap in docs/documentation/amr_multilevel.md. Core idea: replace "coarse = L0" with "coarse for level l+1 = level l" throughout the coupling; flat block pool + per-block level/parent tag so SFC distribution / lazy alloc / repartition-restart / P2P migration all carry over level-agnostically.
  • 2a/2b — level-aware coupling: per-block amr_block_level; parent-block finder; a level-l block's coarse side is its parent block's fine cells (parent-fine index frame). gather-from-parent / prolong / restrict / conservation-check reused unchanged. np=1 restrict∘prolong = identity, exact.
  • 3 — coupling out of a level-2 block: restrict-to-parent + Berger–Colella reflux-to-parent (both required for conservation); persistent L2 block; level-loop driver in m_time_steppers (lock-step). Conserves to machine zero, CPU + GPU. Dynamic per-level regrid + L2→L1 reflux; viscous/chemistry via total-flux reflux; generalized nesting to >2 levels.
  • np 2 parallel test cases don't work with --no-mpi build option MFlowCode/MFC#26 — recursive subcycling: each level advances at its own dt; extract s_amr_advance_subtree; fix pslot/amr_cur arg-aliasing conservation bug.
  • Bubble code is not in m_rhs? MFlowCode/MFC#27 — np≥2 multi-level: co-located refinement towers + distributed nesting (global tag frame + MPI_ALLREDUCE(LOR), owner-guarded sensor); static and dynamic; 1D → 2D/3D.
  • Fixing examples bug MFlowCode/MFC#28 — per-substep fine-fine seam halo: transpose the subcycle driver so tiled same-level sub-blocks reconcile shared-seam ghosts each substep; fixes an np>1 conservation leak.
  • Website favicon MFlowCode/MFC#35 — L2–L2 seam in lock-step: level-aware fine-fine halo extent (2**level, not ) + reflux seam-face exclusion; enables tiled/wide L2 at np≥3.

Validation

  • Conservation to machine zero on CPU + GPU (V100), np=1–4, inviscid and viscous/chemistry.
  • Dedicated AMR goldens for each milestone (static + dynamic multi-level, subcycle, tiled L2).
  • GPU weak-scaling study: ~85% efficiency 2→8 V100 across 4 nodes, machine-zero conservation throughout.

Known scope / follow-ups

  • Subcycle L2 tiling (transpose s_amr_advance_children) — low priority; scaling uses lock-step.
  • Banked: pb/mv non-polytropic QBMM np≥2 coupling; IB-marker ref-ratio-4.

…ated) - Foundation for multi-level AMR nesting (arbitrary refinement depth L0..L_amr_max_level). Add the amr_max_level namelist param (default 1 = current single-level behavior); gate amr_max_level>1 fail-closed in m_checker (not yet supported), documented in case.md + descriptions.py. docs/documentation/amr_multilevel.md captures the design: the core generalization is 'coarse = L0' -> 'coarse for level l+1 = level l' throughout the coupling; FLAT block pool + per-block level/parent tag (so the SFC distribution / lazy owned-only alloc / repartition-restart / P2P migration machinery all carry over unchanged - level-agnostic); RECURSIVE subcycling with time-interpolated C/F BCs (Berger-Colella). Increments: (1) param+gate [this], (2) recursive coupling, (3) recursive subcycle, (4) per-level regrid+nesting, (5) restart/distribution/GPU per level. VALIDATED CPU reldebug: amr_max_level=2 aborts with the gate; 21C71558 + 6C20B752 (np=1) bit-identical (default 1 neutral).
…block_level(:) (refinement level of each active block, 1..amr_max_level) + amr_num_levels to the replicated block metadata in m_global_parameters, allocated + initialized to 1 alongside amr_block_owner and freed at finalize. Both are 1 today (single fine level); the regrid will tag each block's level once multi-level nesting lands. This is the foundation the level-aware coupling (increment 2b) keys off: a level-l block's coarse side is level l-1 (the L0 base grid when l==1), the covering coarser block found by region overlap on the replicated boxes. Bit-identical at amr_max_level=1 (the field is written but not yet read; the level>1 paths stay gated in m_checker). VALIDATED CPU reldebug -b mpirun: 21C71558 + 6C20B752 (np=1 IGR) + 5EFB3277 (np=2) all pass.
… - Add f_amr_parent_block(k): the covering level-(level(k)-1) block that block k refines - its coarse parent - or 0 when block k is level 1 (parent = L0 base grid), found by region overlap on the replicated boxes (proper nesting => exactly one). Pure, bit-identical (unused until the level-aware coupling wires it in). Document the ONE hard detail of the coupling in amr_multilevel.md: the prolong reuses its amr_cg frame unchanged IF amr_cg + (amr_isect_lo, amr_cpat_off, ref_ratio) are in PARENT-LEVEL cell indices. Level-2 block with L0 region R2, parent L1 block p (L0 region R1): parent-fine index of L0 cell c = 2*(c-R1.lo); coarse footprint isect = 2*(R2-R1.lo); fine extent m = ref_ratio^2*|R2|-1; amr_cg gathered from amr_slots(p)%q_cons; coarse coords = the parent's fine coords (needed only for advance/stretched, so a uniform-grid np=1 prolong->restrict operator self-check is the first testable milestone). s_set_amr_fine_geometry + s_amr_gather_coarse_patch each gain a level==1?L0:parent-fine branch. VALIDATED CPU reldebug: 21C71558 (np=1) bit-identical.
…lestone) - Implement the level-aware coarse frame so a level>=2 block's coarse side is its PARENT block's fine cells. s_set_amr_fine_geometry: for level>=2, express the footprint in parent-fine indices (isect = parent_ref_ratio*(R2 - parent R1.lo), m = ref_ratio*footprint) and build coords by bisecting the parent's fine coords. s_amr_gather_from_parent: fill amr_cg from the parent block's fine array in that frame (np=1 local copy; np>=2 P2P is future work). The prolong / restrict / conservation-check are REUSED UNCHANGED - they read (amr_isect_lo, amr_cpat_off, ref_ratio), which is all the coarse frame needs. s_amr_test_multilevel: a np=1 static self-test that nests a level-2 block inside block 1, populates it (level-aware gather + prolong), checks that restrict(fine) recovers the parent-fine coarse, then removes the block (non-intrusive). Relax the m_checker gate to allow np=1 amr_max_level=2 (development self-test; the recursive advance is increment 3). VALIDATED CPU reldebug np=1: MULTILEVEL self-test restrict-prolong conservation err = 0.0000E+00 (EXACT); full run completes and conserves (mass 1.2e-16 / energy 5.7e-16).
…- After the 2b L1->L2 coupling milestone (conservation err = 0.0 on CPU AND GPU/V100), record the increment-3 plan: build the coupling OUT of a level-2 block (restrict-to-parent + Berger-Colella reflux-to-parent, both required for conservation) + a persistent static L2 block + a level-loop driver in m_time_steppers. Lock-step FIRST (all levels at L0 dt, interleaved per RK stage - extends today's non-subcycle mode) to a np=1 static 2-level runs-and-conserves milestone, THEN recursive subcycling on top (generalizing s_advance_amr_fine_substeps, already Berger-Colella for L0<->L1).
…rement 3 step 1) - s_restrict_fine_to_coarse gains a level>=2 branch: instead of folding into the L0 coarse_tgt, s_amr_restrict_to_parent restricts the block's fine averages into its PARENT block's fine array over the covered cells, using the SAME s_amr_restrict_overwrite_device child-sum kernel targeted at the parent in the parent-fine frame (amr_isect already parent-fine; offset 0 = the parent's local fine indexing). Mirror of the 2b gather-from-parent; np=1 local, np>=2 P2P scatter is future work. The self-test now also folds L2 back into the parent and checks the covered cells are unchanged (restrict(prolong) = identity). VALIDATED CPU reldebug np=1: restrict-prolong conservation err = 0.0 AND restrict-to-parent identity err = 0.0 (both EXACT); run conserves (mass 1.2e-16). Next (increment 3): persistent L2 block + level-loop driver (advance L2), then Berger-Colella reflux-to-parent for exact conservation of the running case.
…arent (increment 3 step 2)

Mirror of restrict-to-parent: a level>=2 block's fine flux registers correct its PARENT
block's cells (the coarse side of level l is level l-1), not the L0 grid. s_amr_reflux_to_parent
+ s_amr_reflux_apply_parent apply the same Berger-Colella correction as the L0 s_amr_apply_reflux
but target amr_slots(pblk)%q_cons in the parent-fine frame: the block footprint is amr_isect
(already parent-fine, offset 0), so the outside cells are amr_isect_lo-1 / amr_isect_hi+1, and
q_parent(outside) += dt*(F_coarse - Fbar_fine)/dxf (low) / +(Fbar_fine - F_coarse)/dxf (high),
Fbar_fine = child-averaged fine register. Three face blocks; collapsed dims pin to 0. State form
(the lock-step advance applies it after restrict-to-parent); np=1 local, np>=2 P2P freg delivery
is future work; uniform parent-fine dx (stretched-grid coords TODO). creg is now public from
m_amr_registers (freg already was).

The np=1 self-test (s_amr_test_multilevel) gains a reflux check: inject a known x-face C/F flux
mismatch into the L2 block's registers, confirm the correction deposits exactly the mismatch/dxf
into the parent's outside cells (reflux is conservative - the flux crossing the boundary is
redeposited, not lost), then RESTORE the perturbation so the self-test stays non-intrusive.

VALIDATED CPU np=1: reflux-to-parent conservation err = 3.5527E-15 (machine zero) alongside
restrict-prolong = 0 and restrict-to-parent identity = 0; full run conserves. Single-level AMR
goldens 21C71558 + 852CCB81 bit-identical (all new code is level>=2-gated; creg export neutral).
Next (increment 3): persistent L2 block + level-loop driver, then the lock-step advance conserves.
The np=1 self-test (s_amr_test_multilevel) no longer removes the level-2 block it nests:
amr_num_blocks stays = L2 and amr_block_level(L2) = 2, so the block persists across timesteps for
the advance driver to step. The self-test's operator checks (restrict-prolong, restrict-to-parent
identity, reflux-to-parent) still run at init.

GPU-safe non-intrusiveness: those checks fold L2 into block 1 and perturb its outside cells on the
DEVICE, so the whole intrusive region is now bracketed by a FULL block-1 save (device->host->buffer)
and restore (buffer->host->device). The previous partial per-cell restores left block 1's device
copy inconsistent - a GPU-only NaN by step 3 that CPU (host==device) never showed. The persistent
parent now enters the advance exactly as s_populate_amr_fine left it.

The advance is not yet level-aware (increment 3 step 4), so the three m_time_steppers block loops
(stage fill, stage advance, post-stage restrict/reflux) skip level>=2: the persistent L2 is INERT
(populated at init, not filled/advanced/restricted), so it cannot mis-couple to L0 or fold stale
fine data into the evolving parent. s_amr_conservation_check returns early at amr_num_blocks>1 and
the mass/energy drift sums the L0 grid, so the inert nested L2 is invisible to conservation.

VALIDATED np=1 static (amr_regrid_int=0) 2-level, 6-7 steps, CONSERVES on CPU AND GPU (nvhpc acc,
V100): mass drift 6.99E-13 / energy 9.89E-13 (identical CPU/GPU); self-tests 0 / 0 / 3.55E-15.
Single-level AMR goldens 21C71558 + 852CCB81 + 5EFB3277 bit-identical. Next (increment 3 step 4):
the level-loop driver makes the L2 live (fill/advance/restrict/reflux to parent) - removes the guards.
…N (increment 3 step 4)

Lock-step multi-level advance in m_time_steppers: FILL every block top-down (level-aware s_amr_fine_stage_fill gathers a level>=2 block from its PARENT), ADVANCE all blocks, RESTRICT bottom-up (reverse slot order so the child folds into its parent before the parent folds into L0). level>=2 blocks skip the L0 reflux (parent-flux capture is step 4b).

The intractable GPU-only NaN was a one-line omission: s_amr_test_multilevel prolongs the persistent L2 block via s_interpolate_coarse_to_fine (s_prolong_one_var is a HOST loop) but never pushed the result to the device, so the freshly ACC_SETUP'd L2 device q_cons stayed NaN and folded into the parent via restrict-to-parent. Added the GPU_UPDATE(device) after the prolong, mirroring s_populate_amr_fine. Invisible on CPU (host==device); localised with count(x/=x) after nvfortran maxval hid the NaN, and compute-sanitizer memcheck came back clean (ruling out OOB, pointing at the missing host-to-device sync).

Also: level-aware s_amr_swap_to_fine ghost coords (a level>=2 block sources its parent's fine coords, not the L0 amr_g?cb which its parent-fine amr_isect indexes out of bounds); gather-from-parent copies via a device kernel (s_amr_copy_parent_patch, parent q_cons passed as an argument - present-table safe like s_amr_restrict_overwrite_device).

VALIDATED np=1 static 2-level, CPU and GPU (V100): self-test errs 0 and 3.55e-15; full run completes with NO NaN and conserves (mass 5.0e-13, energy 7.1e-13, identical CPU/GPU).
…o (np=1, 2 levels)

Make s_amr_regrid hierarchical so level-2 blocks are created/destroyed dynamically nested inside level-1 blocks (replacing the static self-test block), and add the L2->L1 Berger-Colella reflux that the dynamic case needs for exact conservation.

Regrid (m_amr.fpp): after the L0->L1 boxes are clustered, append a nested level-2 box per level-1 box (A1 uses a fixed inset; the fine-solution sensor is a follow-up), tag amr_block_level per box, and order the box list parents-first so the build loop fills a parent before its child's gather-from-parent reads it. THE conservation-critical fix: the new block's overlap-copy (which restores fine detail from the old blocks' stash) must only copy from SAME-LEVEL old blocks - an old level-2 stash is in the 4x parent-fine frame, so copying it into a new level-1 block with the L0-frame shift 2*(isect-old_ilo) corrupted the parent (the L2 came out uniform high-density, injecting +6% mass on the first advance). Track old_level per old block and skip cross-level overlap sources; level-2 destinations re-prolong from their fresh parent (detail-preserving same-level L2 migration is future work).

Reflux (m_amr_registers.fpp + m_time_steppers.fpp): capture creg for each level-2 child from its PARENT block's fine flux during the parent's advance (child footprint = amr_isect in the parent-fine frame), rk3_w-weighted for the once-per-step STATE correction; give freg the same rk3_w weighting for level-2 blocks; skip level-2 blocks in the L0 coarse-creg loop; call s_amr_reflux_to_parent(dt) per level-2 block in the lock-step restrict loop. Advective flux only - viscous/chemistry-diffusion multi-level reflux is now gated fail-closed in m_checker.

VALIDATED CPU np=1 (scratchpad hyb_dyn.py, central shock, reflective bc): dynamic level-2 regrid tracks the moving shock and conserves mass 1.97e-16 / energy 0.0 (was 0.12 before the overlap-copy fix, 3.9e-5 with the fix but no reflux). No regression: single-level dynamic regrid 3.9e-16, static multi-level self-test 5e-13 with errs 0/0/3.55e-15.
…(np=1)

The prior commit gated viscous/chemistry-diffusion multi-level fail-closed because the L2->L1 reflux captured only the advective flux. It turned out the NaN that motivated the gate was NOT the reflux but the viscous CFL: the level-2 cells are ref_ratio^2 finer, so their parabolic (diffusion) stability limit is 16x tighter, and lock-step advances every level at the L0 dt - so the L2 viscous advance was simply unstable. With a dt that respects the finest level's viscous stability, viscous multi-level runs; the efficient fix (only the fine levels take small substeps) is subcycling (still TODO).

For conservation, extend the L2 child-creg capture to add the viscous (flux_src on mom..E) and chemistry-diffusion (flux_src on species, plus energy when not viscous) face fluxes, so the L2 reflux matches the TOTAL advective+viscous+diffusive flux exactly as the single-level coarse creg already does. Drop the m_checker gate.

VALIDATED CPU np=1: viscous multi-level (scratchpad hyb_dyn_visc, Re=100, dt=5e-6 viscous-stable, reflective bc) conserves mass 0.0 / energy 0.0 (machine zero); at too-large dt it NaNs on the L2 viscous CFL as expected. No regression: inviscid multi-level 1.97e-16, viscous single-level 3.9e-16. GPU validation to follow.
Replace the single level-2 append pass in s_amr_regrid with a hierarchical loop that appends a box at level l nested inside each level-(l-1) box for l = 2..amr_max_level, tracking each box's level in box_level(:) and ordering parents-before-children so the build loop fills a parent before its child's gather-from-parent reads it. The assembly now tags amr_block_level from box_level and sets amr_num_levels = max level present. Drop the amr_max_level > 2 checker gate.

Everything downstream was already recursive: the level-aware geometry/gather/prolong/restrict (a level-l block's coarse side is its level-(l-1) parent), the child-creg reflux capture (captured during ANY fine block's advance for its level+1 children), and the reverse-slot-order restrict/reflux (finest first) all key off amr_block_level, so no per-level special-casing was needed.

VALIDATED CPU np=1: amr_max_level=3 (scratchpad hyb_l3.py, m=255, reflective bc) nests L1[121:135] > L2[124:132] > L3[127:129] and conserves mass 1.97e-16 / energy 0.0 (machine zero). No regression: amr_max_level=2 still 1.97e-16. Fixed-inset placement shrinks ~2*inset per level, so deep nesting needs a broad feature (sensor-on-fine tagging is a follow-up). GPU validation to follow.
…havior-preserving refactor

Split s_advance_amr_fine_substeps into (a) L0-sourced ghost setup and (b) a new
recursive subroutine s_amr_advance_subtree that subcycles the currently-selected block
(amr_cur) over its two SSP-RK3 substeps and zeroes/accumulates its flux registers. Pure
code motion: the substep loop body is byte-identical, so depth-1 (single-level L0<->L1)
behavior is unchanged. The recursive keyword + a future child clause are the seam for
multi-level subcycling (inc.2).

VALIDATED CPU: 10/10 AMR subcycle/two-fluid/viscous/QBMM/multi-level goldens byte-
identical. VALIDATED GPU (V100, OpenACC): --gpu acc build clean; subcycle (852CCB81) +
multi-level (75AD6885) golden-match; self-tests exact, mass/energy drift ~1e-13.
…l at its own dt

Build the coupling into a recursion out of inc.1's s_amr_advance_subtree:
- s_amr_advance_subtree gains a dt_sub arg (top call passes amr_dt_fine=dt/2; the
  recursion halves), so per-level dt (L1 dt/2, L2 dt/4, ...) falls out of the recursion.
- s_amr_gather_from_parent_field(pblk, qp): gather a level>=2 block's coarse patch from a
  SPECIFIC parent snapshot field (reuses the existing s_amr_copy_parent_patch GPU kernel);
  the lock-step s_amr_gather_from_parent now delegates to it.
- s_amr_advance_children: after each parent substep (parent @ t_b in q_cons, t_a in
  q_cons_stor - both live for free, no new storage), for each level+1 child gather its two
  ghost-lerp sources from those snapshots, recurse at dt_sub/2, then fold back with
  s_amr_restrict_to_parent + per-substep Berger-Colella s_amr_reflux_to_parent(dt_sub).
- Driver (m_time_steppers) skips level>=2 blocks in the top-level subcycle loop; they are
  advanced/restricted/refluxed inside the parent's recursion.

m_amr_registers needs ZERO changes: the per-substep weights (freg 1/r*rk3_w, creg rk3_w
from the MFlowCode#31 lock-step child-capture) already close conservation, and the capture/consume
timing is self-consistent across the recursion. inc.2 adds NO new GPU device kernels.

VALIDATED np=1: static 2-level + amr_subcycle=T (case D95E1B08). L2 genuinely active
(2-level vs 1-level solution differs 1.66e-2 where the wave crosses the block); global
drift 9.7345E-10 mass / 1.3777E-09 energy = IDENTICAL to the single-level subcycle baseline
(L2<->L1 reflux is internal, adds zero global leak). Self-test exact (reflux 3.55e-15). All
10 existing AMR goldens byte-identical. CPU and GPU(V100/OpenACC) results byte-for-byte
identical - no present-table/sync divergence. New golden D95E1B08 protects the recursion.
…l subcycle conservation

s_amr_advance_children is called as s_amr_advance_children(amr_cur, ...), so its intent(in) dummy pslot is argument-associated with the module variable amr_cur. The in-loop s_amr_select_slot(kc) reassigns amr_cur and silently corrupted pslot, so the final restore left amr_cur on the child slot: the driver's L1->L0 restrict/reflux then operated on the child, the L1 fine solution was discarded, and apply_reflux_state used the child's registers -> conservation leak (up to ~1e-3 with a moving L2; masked in the static golden where the child sits in smooth flow). Copy pslot to a local read before any slot switch. Regenerate D95E1B08 (captured the buggy state) and add 00E15144 (dynamic regrid + subcycle + multi-level). CPU+GPU(V100) both conserve to machine zero (~4e-15); full AMR golden group 47/47.
… refinement towers)

Multi-level (amr_max_level>1) was hard-gated to num_procs=1. Enable it for a STATIC hierarchy (amr_regrid_int=0) at np>=2 via co-located refinement towers: the L2 child inherits its parent's rank (s_amr_test_multilevel already sets amr_block_owner(L2)=amr_block_owner(1)), so the L1<->L2 fold stays LOCAL (bit-identical to np=1) and only L0<->L1 crosses ranks via the existing Body-1 P2P. Changes: (1) checker gate now only prohibits DYNAMIC (amr_regrid_int>0) multi-level at np>1 - the cross-rank sensor-on-fine nesting is still np=1; (2) un-gate s_amr_test_multilevel for np>=2; (3) owner-guard s_amr_restrict_to_parent + s_amr_reflux_to_parent - without them the lock-step L2->parent fold dereferences a non-owner's unallocated parent slot (SIGSEGV on rank 1); the subcycle path already guarded. Validated: static 2-level conserves machine-zero at np=1/2/4, both lock-step and subcycle; the 4 np=1 multi-level goldens are unchanged (guards are no-ops at np=1). New golden 4644A339 (multi-level static np=2). GPU np=1 unchanged (no-op guards); np>=2 GPU validation is the multi-GPU scaling study (sbatch).
… fine-block migration segfaults; use n>0 not num_dims which is unset at checker time)
…r arg + fine-fine halo buffer sizing on non-owner ranks)
…(missing per-substep fine-fine seam halo, not reflux/regrid ordering)
… subcycle driver for per-substep fine-fine seam halo)
…el-aware fine-fine halo extent + reflux seam-face exclusion + enable L2 tiling)
…zero + static under-refine)

1) s_amr_tile_box: clamp the per-dim tile size to >= 1. The lock-step
   multi-level nesting path passes amr_maxc_fit/2, which is 0 when a rank's
   fine half-extent is 1 (small subdomain at high np) -> integer div-by-zero
   (SIGFPE) instead of refining.
2) static multi-level (amr_max_level>1, amr_regrid_int=0) built its only L2 in
   s_amr_test_multilevel, which SILENTLY returned (no L2, under-resolved 'success')
   when amr_max_blocks was too small. Abort at the point of failure (the L1 tile
   count is only known there) + a checker gate for the trivially-detectable <2 case.
- s_amr_fine_slice now moves only the buff_size-deep near-seam slab it packs/
  unpacks device<->host (interior transverse only), replacing the two full-block
  q_cons GPU_UPDATE loops in s_amr_fine_fine_halo. Byte-identical (same cells,
  same order) at a fraction of the PCIe volume - the halo runs per stage, 6x per
  fine step after the MFlowCode#28 transpose.
- s_amr_reflux_to_parent: swap the sibling-seam detection nest (block outer, dim
  inner) so f_amr_parent_block - an O(nblocks) scan - is evaluated once per
  sibling instead of once per (sibling, dim). Behavior-identical.

Both from the high-effort branch review. GPU (V100): all L1/L2-tiling np=2 goldens
byte-identical (EF58E377 tiled-L2, F57C3A5B, 244B1E42, ADA042A2, 5EFB3277, ...).
…construction

s_amr_test_multilevel was the ONLY builder of the static (amr_regrid_int=0)
level-2 hierarchy, but it also ran ~90 lines of intrusive development checks on
every populate: block-1 full save/restore, restrict-prolong / restrict-to-parent /
reflux-to-parent identity probes (perturbing creg/freg), and three ES12.4 prints
PER STEP. Those identities are now permanently protected by the static multi-level
goldens (75AD6885, 4644A339, ADA042A2, D95E1B08) and the runtime conservation-defect
probe, so the scaffolding is dead weight (and self-test spam) in production.

Remove the checks, keep only the construction (inset -> L2 metadata -> reconcile ->
fine geometry -> prolong -> device push -> persist), and rename the routine to
s_amr_build_static_multilevel to match its now-sole purpose. The checks restored
block 1 exactly and registers are re-zeroed each step, so output is byte-identical:
GPU (V100) static ML goldens all pass unchanged (prints, which no golden captures,
are simply gone).
s_amr_apply_reflux_state (L0/L1, coarse/sidx frame, unit weights from ownership,
ref_ratio=2) and s_amr_reflux_apply_parent (L2->L1, parent-fine frame, sibling-seam
weights, ref_ratio=slot) were near-verbatim copies of the same three-face flux
correction, so a future physics/stencil fix could silently drift the two apart and
break conservation on one path only (the review's concern).

Extract the shared math into s_amr_reflux_apply_faces(q, islot, rr, dtl, olo, ohi,
glo, ghi, woff, w_lo, w_hi, mlo, mhi): the caller passes all framing (outside index,
creg-local loop range, transverse write origin, per-face weights, cell widths). Both
callers become thin wrappers; the L2->L1 copy is deleted (net -60 lines).

A zero weight now SKIPS the write (not multiply-by-0) so an unowned coarse face's
out-of-bounds outside index is never touched. Output uses the wp-add store (matches
the coarse path; byte-identical to both in the default double build, and the more
accurate choice in the untested --mixed config).

GPU (V100): all 12 subcycle-single-level + multi-level goldens (1D/2D, static/dynamic/
tiled) byte-identical. (A GPU-only present-table trap from array-element loop bounds
was caught by this validation and fixed by hoisting the bounds to scalars.)
The coarse-side creg capture (islot loop, region/sidx frame, per-face ownership
gating) and the child creg capture (kc loop, parent-fine frame, both faces) in
s_amr_capture_boundary_flux were near-duplicate: same advective + total-flux-viscous
+ chemistry-species/energy math over two frames. A future physics term added to one
side but not the other would silently break conservation on one path (the review's
concern).

Extract two shared kernels: s_amr_capture_creg_dense (dense eq range, per-face gated,
accumulate-or-overwrite via merge(creg,0,acc)+cf*flux) covers advective and viscous;
s_amr_capture_creg_chem covers the species-seq + conditional-energy chemistry capture.
The coarse and child branches now just compute their frame and call the same kernels
(net ~-190 lines). The merge idiom is byte-identical to the old if(accum)/else and
never reads uninitialized creg (merge selects, no arithmetic).

GPU (V100): 20 goldens across inviscid / viscous / chemistry / bubbles / QBMM /
multi-level / subcycle / 3D all byte-identical.
@sbryngelson
sbryngelson marked this pull request as ready for review July 13, 2026 01:22
@github-actions

Copy link
Copy Markdown

Claude Code Review

Head SHA: 99ff235

Files changed:

  • 10
  • docs/documentation/amr_multilevel.md
  • docs/documentation/case.md
  • src/simulation/m_amr.fpp
  • src/simulation/m_amr_registers.fpp
  • src/simulation/m_checker.fpp
  • src/simulation/m_global_parameters.fpp
  • src/simulation/m_time_steppers.fpp
  • toolchain/mfc/params/definitions.py
  • toolchain/mfc/params/descriptions.py
  • toolchain/mfc/test/cases.py

Findings:

  • amr_max_level has no upper bound, but the static-hierarchy builder only ever creates one extra level, silently under-refining for amr_max_level >= 3. m_checker.fpp only prohibits amr_max_level < 1 and constrains amr_max_blocks/num_procs/ib combinations when amr_max_level > 1 — nothing rejects amr_max_level >= 3. When amr_regrid_int = 0 (static hierarchy), s_populate_amr_fine calls s_amr_build_static_multilevel whenever amr_max_level >= 2, but that routine (src/simulation/m_amr.fpp) is hardcoded to nest exactly one level-2 block under level-1 block 1 (L2 = n1 + 1; amr_block_level(L2) = 2; ... amr_num_blocks = L2; amr_num_levels = 2) and never loops for deeper levels. A case with amr_max_level = 3 (or higher) and amr_regrid_int = 0 will pass the checker, run, and silently cap at a 2-level hierarchy instead of the requested depth — no error, no warning. This is exactly the "answers that are subtly wrong" failure mode the project's own conventions call out as the highest-priority risk. Either clamp/validate amr_max_level <= 2 in m_checker.fpp until deeper static nesting is implemented, or make s_amr_build_static_multilevel iterate through amr_max_level (or emit a diagnostic when it can't reach the requested depth).

@github-actions

Copy link
Copy Markdown

Claude Code Review

Head SHA: 99ff235

Files changed:

  • 10
  • docs/documentation/amr_multilevel.md
  • docs/documentation/case.md
  • src/simulation/m_amr.fpp
  • src/simulation/m_amr_registers.fpp
  • src/simulation/m_checker.fpp
  • src/simulation/m_global_parameters.fpp
  • src/simulation/m_time_steppers.fpp
  • toolchain/mfc/params/definitions.py
  • toolchain/mfc/params/descriptions.py
  • toolchain/mfc/test/cases.py

Findings:

  • src/simulation/m_amr.fpp: the new f_amr_parent_block(k) returns p = 0 when no covering level-(level(k)-1) box is found, but every call site dereferences the result unchecked: s_set_amr_fine_geometry does rr = amr_slots(pblk)%ref_ratio and later amr_slots(pblk)%x_cb, s_amr_gather_from_parent/s_amr_restrict_to_parent/s_amr_reflux_to_parent all index amr_slots(pblk)%..., and s_amr_assign_block_owners's new tower-rollup loop (do while (amr_block_level(a) > 1); a = f_amr_parent_block(a); end do) would read amr_block_level(0) if a parent lookup ever fails. amr_slots/amr_block_level are allocated 1:amr_max_blocks, so pblk/a == 0 is an out-of-bounds access with no assertion protecting the "properly nested" invariant these paths all depend on.
  • src/simulation/m_amr.fpp (regrid, step 3b): the level-2 tile-size cap is hard-coded to amr_maxc_fit/2 (both in the amr_subcycle branch and the s_amr_tile_box(..., amr_maxc_fit/2) call), based on the comment that "a level>=2 block's fine grid spans 4x its L0 extent while the slot holds 2*amr_maxc_fit fine cells." That factor is level-specific (fine extent scales as 2**lev), but the cap does not depend on lev inside the do lev = 2, amr_max_level loop, and m_checker.fpp's new @:PROHIBITs never bound amr_max_level above 1 other than requiring >= 1. For amr_max_level >= 3 a level-3 child sized against the same amr_maxc_fit/2 cap would be twice as large as the slot can hold, risking an out-of-bounds fine-array write/read. Separately, s_amr_build_static_multilevel (used when amr_regrid_int = 0) only ever builds a single level-2 block regardless of amr_max_level, so a static hierarchy declared with amr_max_level = 3 silently stays at depth 2 with no error.
  • toolchain/mfc/params/descriptions.py and docs/documentation/case.md: the new amr_max_level description says "Only 1 is currently supported; multi-level nesting is planned," but this same PR adds and golden-tests amr_max_level = 2 throughout toolchain/mfc/test/cases.py (goldens (h)-(p)). The description is stale/incorrect relative to the feature this PR ships and doesn't warn about the actual unsupported case (amr_max_level >= 3, see above).

…ware extents, nesting guard

Review of PR #6 (amr-multilevel). Correctness/safety fixes:
- static builder (s_amr_build_static_multilevel): fail-closed guards for an inverted L2 box
  (small parent) and for an L2 whose L0-extent > amr_maxc_fit/2 (would overrun the creg register
  0:amr_maxc_fit-1 in the L2->L1 reflux capture) — the dynamic regrid path already caps/clamps.
- checker: gate multi-level+IB at ALL rank counts (was np>1 only); gate static AMR
  (amr_regrid_int=0) to amr_max_level<=2.
- proper-nesting guard in the regrid: abort fail-closed if a level>=2 block overlaps != 1
  parent-level block (gather/reflux take the FIRST overlap; a tile straddling a parent-tile seam
  would silently couple to one parent — wrong BC + leak).
- stretched-grid reflux-to-parent: per-face parent-fine dx mirroring s_amr_apply_reflux_state
  (byte-identical on uniform grids).
- level-aware extents: tower load-weight and the regrid stash old_ext use 2**level (level-2 is 4x
  its L0 footprint); level-1 byte-identical. Fast-path also compares box_level; guard tower
  roll-up against amr_block_level(0).
- fix stale comment: the child creg captures total flux (advective+viscous+chem), not advective
  only, so viscous/chem multi-level conserves.

Validated: 8/8 multi-level AMR goldens byte-identical on GPU (V100); guards are fail-closed.
The amr_max_level description in case.md, descriptions.py, and amr_multilevel.md still said only
level 1 was supported / multi-level was 'planned', contradicting the shipped, golden-tested
amr_max_level=2 (static up to 2, dynamic regrid deeper). Correct all three.
…path (MFlowCode#691)

s_amr_copy_parent_patch synced the gathered coarse patch amr_cg device->host after every parent
gather, but the runtime C/F ghost-fill (s_amr_fine_stage_advance lock-step, s_amr_fill_fine_ghosts
subcycle) reads amr_cg on the DEVICE (filled by the copy kernel); only the init/regrid host prolong
and the restrict-prolong self-test consume it on the host. Thread a to_host flag through
s_amr_gather_from_parent_field -> s_amr_copy_parent_patch (= .not. pull_host: init/regrid true,
runtime false; subcycle call sites explicit false) so the D2H runs only when a host consumer
follows. Removes a per-substep, per-level>=2-block whole-patch device->host transfer.

Validated: 8/8 multi-level AMR goldens byte-identical on GPU (V100).
…t round-trip)

s_amr_gather_coarse_patch (the L0->L1 coarse-BC gather) pulled q_coarse device->host, unpacked it
into amr_cg on the host, then pushed amr_cg host->device - a full round-trip every RK stage per
fine block. At num_procs==1 the sole owner holds every covered coarse cell, so copy q_coarse
(device) -> amr_cg (device) with a DEVICE kernel over the in-domain patch (same index map as
s_amr_unpack_patch), and skip the q_coarse pull. Gated on pull_host (runtime): at init/regrid
q_coarse's device copy may be stale, so those keep the host path. np>1 keeps the host pack for the
MPI P2P gather. amr_cpat_off hoisted to scalars for present-table safety (mirrors
s_amr_copy_parent_patch).

With this + the MFlowCode#691 amr_cg fix, the np=1 AMR advance's coarse-BC gather (L0->L1 and L1->L2) stays
entirely on-device per stage - no host round-trips except the seam halo. Validated: full 56-test
AMR golden suite byte-identical on GPU (V100).
…k/active copies were non-conforming at amr_max_level>=2)
…evel nesting (IB path left it at the cluster count, overrunning on child append)
…clears the body (refine the surface, not the interior)
@sbryngelson
sbryngelson merged commit 517326c into up/mega Jul 13, 2026
58 of 65 checks passed
@sbryngelson
sbryngelson deleted the amr-multilevel branch July 14, 2026 22:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant