Design: Adaptive Mesh Refinement (AMR) for 3D Models — Octrees + Face Mortars#
Status: Implemented (Stages 1–6a, plus Stage-5 v2 point-to-point solution
migration) — mirrors the implemented 2D design. Stages 1–5 landed with #164; the
device-resident solution transfer (Stage 6a's 3D analogue) landed with #165;
point-to-point migration, replacing the allgather-based redistribution in both
2D and 3D, landed with #167. What is still outstanding from §7 is noted in
that table. Sections written in the future tense below have been updated where
the delivered code differs from the plan; where it does not, the plan text is
the record of what was built.
Scope: 3D hexahedral meshes, octree (2:1) h-refinement built on new 3D
2:1 face-mortar interface support, spectral (Legendre modal) refinement
indicator, CPU and GPU backends, MPI-parallel. Pilot model: LinearEuler3D.
Companion documents: Mortar2D-Design.md and AMR2D-Design.md. The 2D stack (mortars → quadtree forest → transfer plan → controller → MPI → GPU) is implemented and validated; this document records only the 3D-specific decisions. Where a 3D module is a mechanical transcription of its 2D counterpart, the 2D document remains the authoritative rationale.
1. Structural mapping from 2D#
| 2D (implemented) | 3D (this plan) |
|---|---|
Mesh2D_t%mortarInfo(1:8,:) (1 big + 2 small edges) |
Mesh3D_t%mortarInfo(1:14,:) (1 big + 4 small faces) |
MortarExchange / MortarFluxCollect on 2D mapped data |
same on MappedScalar3D_t / MappedVector3D_t |
1D mortarR/mortarP applied per edge |
same 1D matrices applied as 2D tensor products per face |
flip ∈ {0,1}; sub-edge pairing t vs 3-t |
flip ∈ 0..7 (dihedral group); quadrant permutation table |
| flux collect factor −2 (sub-edge Jacobian ½) | factor −4 (sub-face Jacobian ¼) |
SELF_QuadTreeMesh_2D (4 children) |
SELF_OctreeMesh_3D (8 children) |
childOfSide(1:2,1:4) |
childOfFace(1:4,1:6) |
RefineConnectivity 4 inner face pairs |
12 inner face pairs |
| transfer: 2-pass tensor product, 4 children, ¼ | 3-pass tensor product, 8 children, ⅛ |
indicator: 2D modal transform  = P U Pᵀ |
3D modal transform (three passes), same criteria |
mortarBuff(N+1,1:4,m,v) |
mortarBuff(N+1,N+1,1:8,m,v) |
dt = dtBase/2**MaxLevel |
identical |
New modules (each mirrors the 2D file of the same name, 2D→3D):
SELF_OctreeMesh_3D.f90, SELF_AdaptiveMesh_3D.f90,
SELF_MeshRefinement_3D.f90, SELF_RefinementPrimitives_3D.f90,
SELF_RefinementIndicator_3D_t.f90 (+ cpu/gpu backends),
SELF_SolutionTransfer_3D.f90, SELF_TransferPlan_3D.f90,
SELF_AMRController_3D.f90.
2. Conventions (normative)#
2.1 Face trace coordinates#
boundary(i,j,side,elem) face coordinates, from BoundaryInterp:
| sides | face (i,j) |
|---|---|
| 1 (Bottom), 6 (Top) | (ξ₁, ξ₂) |
| 2 (South), 4 (North) | (ξ₁, ξ₃) |
| 3 (East), 5 (West) | (ξ₂, ξ₃) |
2.2 Children#
Children are ordered by CGNS corner-node order:
axc = [0,1,1,0,0,1,1,0], ayc = [0,0,1,1,0,0,1,1],
azc = [0,0,0,0,1,1,1,1]; child c covers ξ_d ∈ [a-1, a] per direction.
Subdivision is orientation-preserving: a child face lying on parent face s
is that child's local face s, so neighbor/flip data inherits verbatim
(same load-bearing fact as in 2D).
2.3 Face quadrants and childOfFace#
A big face is split into 4 sub-faces ("quadrants") indexed in the big
face's trace coordinates: q = kx + 2*(ky-1) with kx, ky ∈ {1,2} the
half-interval index along the face's i and j coordinate (1 = [-1,0],
2 = [0,1]). Restriction to quadrant q is the tensor product
R_kx ⊗ R_ky of the existing 1D mortarR; projection back is
P_kx ⊗ P_ky of mortarP.
childOfFace(q,s) — the child of a refined hex whose face covers quadrant
q of parent face s (derived from §2.1 + §2.2):
| s | face | childOfFace(1:4,s) |
|---|---|---|
| 1 | Bottom | 1, 2, 4, 3 |
| 2 | South | 1, 2, 5, 6 |
| 3 | East | 2, 3, 6, 7 |
| 4 | North | 4, 3, 8, 7 |
| 5 | West | 1, 4, 5, 8 |
| 6 | Top | 5, 6, 8, 7 |
Octree helpers: oc_opposite = [6,4,5,2,3,1];
oc_reflect(s,c): s∈{1,6} → [5,6,7,8,1,2,3,4],
s∈{2,4} → [4,3,2,1,8,7,6,5], s∈{3,5} → [2,1,4,3,6,5,8,7];
oc_subpos(c,s) is the inverse of childOfFace.
2.4 Flips and quadrant permutation#
3D flips follow the existing 8-state convention
(SELF_Mesh_3D_t.f90): (i2,j2) = F_f(i1,j1) maps receiver-face indices to
donor-face indices. The same maps applied to half-interval indices give the
quadrant permutation mortarQuadPerm(q,f) — the donor-face quadrant that
coincides with receiver-face quadrant q:
| f | perm(1:4) | f | perm(1:4) | |
|---|---|---|---|---|
| 0 | 1,2,3,4 | 4 | 1,3,2,4 | |
| 1 | 2,1,4,3 | 5 | 2,4,1,3 | |
| 2 | 4,3,2,1 | 6 | 4,2,3,1 | |
| 3 | 3,4,1,2 | 7 | 3,1,4,2 |
On octree-emitted meshes, mortars interior to a root tree always carry flip 0; mortars across a root face inherit the base mesh's face flip. The mortar exchange implements all 8 flips so unstructured (HOHQMesh/HOPr) base meshes are supported.
2.5 mortarInfo(1:14, 1:nMortars)#
Replicated on all ranks; element ids are global. Rows:
1 big element id 2 big local face id
3,4 small elem, 10*face+flip (sub-face covering big-face quadrant 1)
5,6 same for quadrant 2
7,8 same for quadrant 3
9,10 same for quadrant 4
11:14 global side ids of sub-faces 1..4 (MPI message tags)
As in 2D: mortar faces carry sideInfo(3) = 0 and sideInfo(5) = 0
(conforming machinery and BC mapping skip them) and sideInfo(1) = mortar
index; the flip stored with each small face maps big-face quadrant
coordinates to the small face's own coordinates (the same receiver→donor
convention as sideInfo(4)).
2.6 Mortar data plane#
mortarBuff(1:N+1, 1:N+1, 1:8, 1:nMortars, 1:nvar [,1:3]) — slots 1–4 hold
the big-face trace (replicated per quadrant; MPI receives land per-quadrant),
slots 5–8 hold the small-face traces re-oriented into the big face's
coordinates. Exchange algorithm, per mortar:
- Stage rank-local big trace into slots 1–4 and (flip-reoriented) small traces into slots 4+q; MPI posts fill remote slots (small ranks receive the big trace into slot q; the big rank receives small traces into 4+q), with received small traces flip-reoriented after the wait.
- Small
extBoundary(i,j)at face coordsF_f(i,j)←(R_kx ⊗ R_ky)applied to slot q (exact restriction). - Big
extBoundary←Σ_q (P_kx ⊗ P_ky)applied to slots 4+q (L2 projection;mortarPcarries ½ per direction ⇒ ¼ total, the correct solution-trace projection).
MortarFluxCollect (on boundaryNormal): big-face integrand
:= −4 Σ_q (P_kx ⊗ P_ky) g_q. The ¼ from the tensor-product mortarP
combines with the ×4 integrand conversion so that the big face's discrete
surface integral equals minus the sum of the four small faces' integrals to
roundoff — the same conservation argument as 2D with ½/×2.
MPI messages use tags globalSideId + nUniqueSides*(ivar-1) per sub-face,
exactly the 2D scheme; the message pattern remains async point-to-point,
one round per stage.
2.7 Balance and hanging edges#
2:1 balance is enforced across faces only, the direct analogue of 2D
(which balances across edges and tolerates 2-level corner jumps). DG face
mortars carry all interface data; hanging edges/corners carry none, so
face-2:1 balance is sufficient for well-posed connectivity. EmitMesh
guards MaxLevelJump() <= 1 as in 2D.
2.8 Solution transfer#
Operators. Prolongation: U_child = (R_kx ⊗ R_ky ⊗ R_kz) U_parent (exact),
(kx,ky,kz) = (axc(c)+1, ayc(c)+1, azc(c)+1). Restriction:
U_parent = Σ_c (P_kx ⊗ P_ky ⊗ P_kz) U_child(c) — conservative, and
Restrict(Prolong(u)) = u to roundoff via Σ_k P_k R_k = I per direction.
Reference-cell conservation: Σ w³ u_parent = (1/8) Σ_c Σ w³ u_child. Each 1D
P_k carries the half-interval Jacobian, so the triple product carries 1/8.
Implemented in SELF_SolutionTransfer_3D.f90 as three sequential directional
contractions; the loop order there is normative, since CLAUDE.md forbids
reordering floating-point reductions.
Epoch protocol. The plan (SELF_TransferPlan_3D) is built on the host after
the last forest mutation and classifies every new leaf as COPY, PROLONG or
RESTRICT, plus a depth and an octant path for any further descent. It is
applied around the regrid as a three-step, type-bound protocol on the model, so
the backend split that already selects Regrid selects the transfer too:
call model%StageSolutionForTransfer() ! preserve the field; Regrid may then free it
call model%Regrid(newMesh,newGeom)
call model%ApplyTransferPlan(plan,interp,eFirst,eLast)
StageSolutionForTransfer exists because Regrid reallocates (or resizes) the
storage the solution lives in, so the pre-regrid field must be preserved first.
The portable implementation stages into DGModel3D_t%transferStage, a host
array whose lifetime is exactly stage-to-apply — it is allocated by the first
call, consumed and deallocated by the second, and released in Free. Calling
ApplyTransferPlan without a preceding stage is a hard error (stop 1), pinned
by test/dgmodel3d_guard_transfer_unstaged.f90, because the alternative is
reading unallocated storage and failing somewhere less obvious.
ApplyTransferPlan takes an optional uGlobal argument, plus an optional
oldFirst: the multi-rank path. On more than one rank the controller hands
in host-side old-field data and each rank fills exactly its own new contiguous
element range. Two forms exist. By default (Stage-5 v2, §4) uGlobal is the
rank's window of the old field — the contiguous run of old elements its new
range references — and oldFirst is that window's first global old element
index, so the plan's global sourceElem/family indices still resolve
correctly. Under SELF_AMR_MIGRATE_GATHER=1 (Stage-5 v1) it is the whole
allgathered global old field and oldFirst is absent, which is the same call
with oldFirst = 1. Either way migration is a host operation, so that branch
stays on the portable host transfer on every backend; serving the device
transfer from a migrated window is the named follow-up to #167.
Device path. On a single-rank GPU build both steps are overridden
(src/gpu/SELF_DGModel3D.f90): staging is a device-to-device copy into a
model-owned buffer, and the plan is applied by TransferSolution_3D_gpu, so an
adapting run moves no solution data across the host link at all. Consequence
worth knowing: the transferred solution is then left on the device and
solution%interior (the host mirror) is stale after Adapt. That matches
the rest of the time loop, where the device is authoritative, but it is a
behaviour change from the host-only implementation, where the mirror happened to
be fresh. See §5 for the kernel, and the Learning page for the measurements.
2.9 Indicator#
3D tensor Legendre modal transform (three passes with the same Pmodal);
E_tot, E_clip1, E_clip2 computed over the 3D mode cube with the same
top-mode clipping per direction; identical S_e/σ_e definition, amplitude
gate, relative energy floor (default 1e-12), hysteresis band, and the
two-phase (device/host) split that keeps CPU and GPU flags identical.
2.10 EC / split-form models#
ECDGModel3D raises a runtime error on meshes with nMortars > 0
(entropy-stable mortar operators out of scope), mirroring the 2D guard.
3. What carries over unchanged#
Lagrange_t%mortarR/mortarP(+ existing device mirrors) — no new operator math anywhere in the plan.DomainDecomposition_t— contiguous SFC partition of the Morton-ordered leaf list per epoch,elemToRanklocality tests, tag conventions.- Controller architecture: rank-replicated forest, flag allgather,
level cap + halo passes,
AdaptFromFlags(coarsen-then-refine, snapshot based),Balance2to1fixed-point sweeps, no-op detection,BuildTransferPlanwalk over stable node ids,EmitMeshregeneration, geometry double-buffering withCopyElementsreuse (sourceKind == COPY⇒ same forest node ⇒ bit-identical coordinates),RegridviaResize(not Free+Init), Stage-5 v2 point-to-point solution migration (§4),RecommendedTimeStep = dtBase/2**MaxLevel, and theSELF_AMR_GEOM_{FULL,VERIFY,NO_REUSE}andSELF_AMR_MIGRATE_{GATHER,VERIFY}diagnostics.
New 3D-side prerequisites (absent today, required by the controller):
Resize on Scalar3D/Vector3D/Tensor3D/mapped variants,
SEMHex%Resize/CopyElements/GenerateFromNodeCoords/UploadGeometry + cached
scratch + nElem = 0 default, DGModel3D_t%Regrid/
StageSolutionForTransfer/ApplyTransferPlan + transferStage.
Geometry reuse matters more in 3D: CalculateContravariantBasis_SEMHex
uses the curl-invariant metric form and is substantially more expensive than
its 2D counterpart.
4. MPI strategy#
The 2D document's §7 sketched this; what follows is the record of what was built, for both dimensions, and it supersedes that sketch (see the note at the end).
Ordering and partition. The global leaf order is base-mesh root order with
Morton order inside each tree — deterministic and locality preserving. EmitMesh
re-runs GenerateDecomposition over the new leaf count every epoch, so each rank
owns the contiguous range offsetElem(rank+1)+1 … offsetElem(rank+2). Equal
element counts are a valid balance measure because every element carries the same
polynomial degree. Repartitioning is therefore implicit in every epoch, and both
the old and the new partition are contiguous ranges of the same leaf order —
the fact everything below rests on.
Replicated state. The forest is rank-replicated. At Init,
InitForestFromDecomposedMesh allgathers the global base-mesh node coordinates,
side table and material ids (once, at startup). Each epoch the rank-local
indicator flags are allgathered (one int per element) and the indicator's
amplitude gate is reduced with MPI_MAX, so every rank applies identical
mutations and computes an identical transfer plan. Forest memory is therefore
O(global elements) per rank; making the forest distributed is the p4est-style
Stage-2 follow-up in #167 and is deliberately not attempted here.
Solution migration (Stage-5 v2, #167). Migration moves only what changes rank:
- The window. For a rank's new element range
[eFirst,eLast], the old elements its plan entries read —sourceElem, or all eightfamilymembers for a coarsened family — lie in a contiguous window[wFirst,wLast]of the old element list.PlanWindowscomputes the min/max hull of those indices for every rank in one pass over the plan. Correctness does not depend on the leaf ordering being monotone: a non-monotone ordering only widens the hull, the worst case being the whole old list, which is exactly what v1 moved. Locality, not correctness, is what the space-filling curve buys. - Routing needs no communication. Because the plan and both
offsetElemtables are replicated, a rank computes its own window and every peer's window locally. The run it must send to peerris the intersection of its own old range with peerr's window; the run it must receive from peerris the intersection of its window with peerr's old range. Both ends of a pair evaluate the samemax/minon the same integers, so the schedules match by construction — there is no count exchange, no handshake, and no collective. - The exchange.
ExchangeOldWindowposts allMPI_Irecvs before the matchingMPI_Isends and closes with oneMPI_Waitall, the same shape as every other point-to-point exchange in SELF. Nothing is packed: a run of elements at a fixed variable is contiguous in bothsolution%interiorand the window buffer, so each message reads and writes the real storage. That is one message per (peer, variable) — a handful of peers for a balanced repartition, and bounded by the rank count in the worst case — and it removes the pack buffer, the unpack loop and two whole-field copies. - Tags. The tag is the variable index. Ambiguity would require two messages
between the same ordered rank pair carrying the same variable in one exchange,
and each pair exchanges exactly one run per variable. The exchange is drained
before
Regrid, so migration traffic never coexists with the side, mortar or halo exchanges (which useglobalSideId + nUniqueSides*(ivar-1), or tag 0 for the aggregated form) even though all of them share the mesh communicator. - Sequencing. The exchange runs before
model%Regrid:EmitMeshhas already decomposed the new mesh, so both partitions are known, and the sends can read the still-live pre-regrid solution. It also means no traffic is in flight acrossRegrid, which rebuilds mesh and decomposition state on the same communicator. Overlapping the two is a possible future optimization and would need a tag space of its own. - Coarsened families. A family's eight children may be owned by different old
ranks. The window hull covers all eight, each child arrives from whichever rank
owned it, and
RestrictFromChildrenruns on the receiving rank in child-octant order — so the projection's operand order, and therefore its result, is independent of the partition. - Degenerate cases. A rank owning no new elements gets an empty window
(normalized to
wFirst=1, wLast=0), posts no receives, skips the apply, and still posts its sends, because peers may need old elements it owns. An empty peer overlap is skipped identically on both sides. - Memory. The per-rank migration footprint is the window — local elements plus the overhang past the rank's own old range — instead of a full global field. The window buffer is a controller component, persistent and grow-only, so a settled adapting run performs no allocation in this path.
- Bit-identity. The two migrations are bit-identical, not merely close:
ApplyTransferPlanRange(v1) isApplyTransferPlanWindow(v2) over the whole old field, so both feed the same operands to the same operators in the same order, and no reduction order changes.SELF_AMR_MIGRATE_VERIFY=1runs both in one process and compares the window against the allgathered field value by value;SELF_AMR_MIGRATE_GATHER=1selects v1 outright, keeping the retained fallback exercised.
Exchange-table rebuild. The aggregated halo tables (decomp%halo_*) and each
field's persistent halo requests are partition-specific and are torn down on
Resize, so Regrid invalidates them and the next exchange rebuilds them. The
sub-face global side ids in the regenerated mortarInfo keep the tag convention
valid with no new machinery.
Constraint compliance. Every collective and every message lives in the
adaptation epoch, between time steps — never in CalculateTendency or an RK
stage (CLAUDE.md §5). The communicator is unchanged, no reduction is reordered,
and the v1 collective path is retained behind an environment switch rather than
deleted (CLAUDE.md §10).
Supersedes: AMR2D-Design.md §7 specified MPI_Alltoallv for
redistribution. Replicated routing removes the count exchange that motivates
Alltoallv, and the pattern is sparse for a balanced repartition — a rank talks to
a couple of neighbours in leaf order rather than to everyone, with the worst case
bounded by the rank count and handled — so matched Isend/Irecv pairs are both
cheaper and free of an all-ranks synchronization point.
5. GPU strategy#
Same division of labor as 2D: numerics on device, adaptation logic on host.
Kernels follow the existing files' pattern: 3D mortar gather/scatter and
flux-scatter kernels (SELF_Mortar.cpp), a 3D indicator kernel
(SELF_Refinement.cpp, AMR3D_MAXNP bound), and the 3D transfer kernel
(SELF_SolutionTransfer.cpp). All are implemented.
The transfer kernel is TransferSolution_3D_gpu. As planned, the 2D kernel's
one-block-per-element with Np² threads and one thread per node becomes
one-block-per-element with Np² threads where thread (a,b) owns one (a,b)
pencil and loops the free index through each of the three directional passes — the
same decomposition RefinementIndicator_3D_gpukernel uses. A Np³ thread block
is not an option: it is 4096 threads at N=15, past the block limit, and it would
push the working buffers into per-thread scratch.
Three Np³ __shared__ buffers do fit, at a bound: AMR3D_MAXNP is 12, giving
3·12³·8 B = 41,472 B, inside both the 48 KB CUDA static shared-memory limit and
the 64 KB gfx90a/gfx942 LDS budget. The allocation is static, so it is 41,472 B
at every degree, not (N+1)³; a dynamic-shared variant sized to the degree in
use measured 1-3% slower on a B300 and was not adopted (see the Learning page's
§6.4). The Fortran caller guards interp%N+1 <= 12 so a larger degree fails
loudly rather than overrunning.
One deliberate divergence from the host reference, inherited from 2D: the host
descent calls ProlongToChildren, which forms all eight children and discards
seven, whereas the kernel applies only the operator triple of the child actually
on the recorded path — an eighth of the work per descent step. What is dropped is
the seven discarded children, not any part of the retained value: each contraction
sums the same terms against the same mortar column in the same ascending index
order as the host loop, so the reduction order CLAUDE.md protects is preserved.
Device and host nonetheless agree only to round-off rather than bitwise, because
the device compiler contracts these multiply-accumulates into FMAs.
test/solution_transfer_3d_device.f90 pins the kernel's output against the host
reference value by value; the AMR regressions assert the invariants (conservation,
entropy non-growth), which conservation arguments make exact either way.
The window form (#172). TransferSolution_3D_gpu takes an oldFirst0
argument and nOld means uOld's per-variable element STRIDE, not the global
old-leaf count; a plan's global source index is rebased as src - oldFirst0. The
whole-field case is exactly oldFirst0 = 0, so the single-rank path is unchanged
arithmetic. This is what lets a multi-rank adaptation use the kernel at all: the
window of old elements a rank reads is migrated to it point-to-point and, on a GPU
build, assembled in device memory — its own run device-to-device, the peers' runs
MPI_Irecv'd straight into device memory. That requires a GPU-aware MPI, which is
not a new dependency: the per-step aggregated halo exchange has always posted on
device allocations with no fallback, and SELF_REQUIRE_GPU_AWARE_MPI is fatal by
default. The local run is copied by MigrateWindowLocal_gpu, which synchronizes
the device UNCONDITIONALLY before returning, because the caller then posts MPI
against solution%interior_gpu; making that contingent on there being something
to copy would strand a rank whose local run is empty. HaloPack_3D_gpu
synchronizes before its own MPI for the same reason.
A convention this change follows. New index and byte arithmetic belongs in
src/, shared with the host path, so that every CI job checks it rather than only
the two GPU pipelines (see §6) - and so that a CPU build can fail for a
logic error rather than deferring it to hardware. The guard the kernel cannot
perform (every source inside the window) likewise lives in the Fortran caller,
where it can report; inside the kernel an early return would be neither reportable
nor block-uniform, and would strand the __syncthreads() calls.
6. Testing & validation plan#
Serial/CPU (mirroring the 2D suite):
- mappedscalarmortarexchange_3d_linear: linear field reproduced exactly
across a hand-built 3D mortar mesh (all interior faces conforming or
mortar), including a rotated-neighbor variant covering nonzero flips.
- mappedvectordgdivergence_3d_mortar: divergence of a linear flux on a
mortar mesh matches the conforming result; conservation of the big-face
surface integral to roundoff.
- lineareuler3d_mortar_soundwave: acoustic pulse crossing a static mortar
interface; conservation + entropy checks (a conforming-mesh
lineareuler3d_soundwave baseline lands first — LinearEuler3D currently
has no dedicated regression test).
- octree_balance_3d, mesh3d_uniform_refine, adaptive_mortar_3d,
solution_transfer_3d, transfer_plan_3d, refinement_indicator_3d_*:
transcriptions of the 2D unit tests (validity invariants, transfer
exactness/identity/conservation, indicator smooth/front + guards).
- lineareuler3d_amr_soundwave: the 2D soundwave AMR regression in 3D —
adapt-to-convergence, conservation per epoch, dt halving, entropy
non-increase, forest replication checksum.
- Guard tests (WILL_FAIL) for every error stop added, following the 2D
guard-test inventory (controller decomp/maxlevel/wrongmesh, unbalanced
emit, transfer-plan misuse, indicator argument guards, EC mortar guard).
MPI: mappedscalarmortarexchange_3d_linear_mpi,
lineareuler3d_mortar_soundwave_mpi, lineareuler3d_amr_soundwave_mpi
(partition-changing adaptation), geometry_3d_reuse_mpi + env-gated verify.
Solution migration (§4, #167) is covered by four additions, chosen so that each one can fail for a different reason:
transfer_plan_3d_window(serial): drivesPlanWindowsandOwnedRunover a table of hand-written (old, new) partition pairs a 2-rank run could never produce - empty new ranges, empty old ranges, a window spanning four peers, a window that is entirely remote - and checks window coverage, window tightness (both ends are actually referenced, so nothing surplus moves), and that the windowed apply is bit-identical to the whole-field apply. It fails if the table stops reaching those configurations.- Three
WILL_FAILguards on the windowed apply, one per way a wrong window fails:transfer_plan_3d_guard_window(the window misses a referencedsourceElem),..._guard_windowfamily(it misses one of the eight children of a coarsened family - the case that arises when a family straddled a rank boundary), and..._guard_windowbounds(the window is not inside1..nOldat all). Each muststop 1rather than read outside the array. amr_migrate_3d_window_mpi(2 and 4 ranks): the realExchangeOldWindow, on a deliberately ASYMMETRIC adaptation so old elements genuinely change rank. The old field is analytic and encodes each element's global index in its values, so a misrouted element names itself. It asserts that at least one rank received something from a peer - without that, a symmetric refinement can leave every window local, the exchange a no-op, and a passing verify run meaningless.-
SELF_AMR_MIGRATE_VERIFY=1/SELF_AMR_MIGRATE_GATHER=1re-runs of the existing AMR MPI regressions at 2 and 4 ranks: the first asserts v1 and v2 agree value for value in-process on real physics, the second keeps the retained v1 path exercised. -
amr_migrate_3d_device_apply_mpi(2 and 4 ranks, #172): the same asymmetric epoch, but driven through the MODEL - migrate, regrid, apply - so that on a GPU build it exercises the device window assembly, the receive into device memory and the kernel'soldFirst0, none of whichamr_migrate_3d_window_mpireaches (that one callsExchangeOldWindowdirectly, always in host memory). It fails unless some rank received elements from a peer AND some rank's window base is both past 1 and away from that rank's own first old element - the second condition because a window beginning exactly where the rank's old range begins cannot distinguish a correct rebase from a dropped one. Values are checked againstApplyTransferPlanRangeover an independently built analytic field: bitwise on a CPU build, to a tolerance on GPU. solution_transfer_3d_device_offset(#172): calls the launcher DIRECTLY with a synthetic window and a nonzerooldFirst0, which separates a kernel indexing error from a migration error. GPU-gated, unavoidably - it is the one gated test in that change, because the kernel is the subject and a CPU build has no second implementation to compare against.SELF_AMR_MIGRATE_VERIFY=1/SELF_AMR_MIGRATE_GATHER=1re-runs, plusSELF_AMR_TRANSFER_VERIFY=1in 2-D. These check DIFFERENT things at different bars and must stay separate:MIGRATE_VERIFYasserts the window arrived intact and is BITWISE, because migration is byte movement;TRANSFER_VERIFYasserts the two applies agree and is tolerance-based, because the device compiler contracts the kernel's multiply-accumulates into FMAs.SELF_AMR_TRANSFER_HOST=1is deliberately NOT registered as a GitHub Actions re-run: on a CPU build it selects the same code the default takes, so it would cost 33 s across both dimensions for a few lines of branch coverage in a job that already runs within minutes of its 90-minute ceiling. It is exercised where it selects different code - the on-hardware GPU pipelines, and the B300 verification runs, which do an explicitTRANSFER_HOST=1pass over the whole set.
On patch coverage, and one thing that is easy to get wrong about it. A WILL_FAIL
test's stop 1 DOES produce coverage data - gcov's atexit flush runs normally, and
the guard lines it reaches show real hit counts. So guard paths are ordinarily
coverable and a WILL_FAIL test is the way to cover them; #172 initially assumed
otherwise and left twelve reachable lines untested on that mistaken basis.
What genuinely does not get covered is narrower. Assertion branches such as
VerifyWindowedApply's mismatch report fire only when the host and device applies
disagree beyond tolerance, which needs data corrupted on purpose - leave those, and
do not delete an assertion to move a number. Continuation lines of multi-line calls
are reported as never executed no matter what, because gcov attributes a statement
to its first line. And MPI_IRECV/ISEND/WAITALL inside a multi-rank test are
unreliable: the ranks share one .gcda path and clobber each other, so whichever
writes last decides which side appears covered.
Codecov's default patch target is the project's own coverage (~93%). Cover what a test can genuinely reach - which is more than it first appears - and say which lines are deliberately left.
Test cost is a real constraint here, but runner speed is the larger term and the
two are easy to confuse. Observed gfortran-12 coverage durations on one branch,
against a hard timeout-minutes: 90: 85 min, 90 min (cancelled), 82 min, and
43 min - where the 43-minute run carried MORE tests than the 82-minute one. So the
job sits near its ceiling and a slow runner, rather than a few added seconds of
tests, is what decides whether it gets cancelled. Commit d88910d4 trimmed the 3-D
AMR soundwave family for this reason and the constraint clearly recurs, so anything
added to the AMR MPI family is still worth costing
(Testing/Temporary/CTestCostData.txt) before it is registered. But a trim should
not be expected to fix a cancellation on its own, and coverage should not be traded
away for seconds a fast runner would have absorbed - #172 did that once and had to
put them back.
Two ranks are not enough on their own: with one peer the send and receive runs
are forced and no window can straddle more than one peer, which is why the
4-rank entries exist (SELF_MPIEXEC_NUMPROCS_MANY, default 4).
GPU coverage comes from two places, and it is worth being precise about which.
Most GitHub Actions jobs (.github/workflows/linux-*) are CPU-only and compile no
device kernel, but amd-mi210-gpu-tests.yml is the exception: it runs on the
self-hosted galapagos-mi210x4 runner, under rootless podman on the selfish base
image, and does an on-hardware AMD MI210 (HIP, gfx90a) coverage build and full
ctest. Buildkite (.buildkite/pipeline.yml) supplies the rest: on every non-main
branch it uploads an NVIDIA V100 (CUDA, sm_70) on-hardware coverage pipeline and an
x86 CPU pipeline. Both GPU pipelines configure SELF_ENABLE_GPU=ON. So a pull
request does build and run both device backends, and src/gpu/ coverage is uploaded
from both.
Two consequences for the tests above. A GPU-gated test is not dead weight - it runs
on both backends per PR - but it is invisible to the much faster and more numerous
GitHub Actions jobs, so the default remains NOT to gate: a test that can assert
something real on a CPU build should. And keeping new index arithmetic in src/
rather than src/gpu/ still earns its keep, not because the device path is
uncompiled, but because arithmetic shared with the host path is checked by every
job rather than only by the two GPU ones. Deeper GPU work still wants a dedicated
node for measurement (a B300, CUDA sm_103, for #165/#167/#172), since the GPU
pipelines are correctness gates and not a benchmarking environment.
Which COMPILERS the estate actually exercises, which is narrower than the support
matrix. Every job that runs on a pull request uses gfortran: the GitHub
Actions matrices (9/10/11/12, debug/release/coverage), the MI210 GitHub Actions job
and the two remaining Buildkite pipelines, which set FC=gfortran and vary only the
GPU backend. The
linux-amdflang-cmake and linux-nvidia-hpc-cmake workflows exist but are
currently disabled_manually in the repository, and there is no ifx job at all.
So the compatibility this project asks for in CLAUDE.md - gfortran >= 11, ifx,
nvfortran, amdflang - is a standing requirement that CI does not verify for three
of the four. Anything resting on a less common language feature (sequence
association, non-default integer kinds in unusual positions, type-bound override
attribute matching) is checked here only against gfortran, and a green PR should
not be read as more than that.
CPU/GPU flag agreement is enforced by construction (shared host
FinalizeIndicator); post-adapt solution agreement by tolerance.
7. Work breakdown & phasing#
| Stage | Content | Mirrors |
|---|---|---|
| 1 | 3D face mortars: mortarInfo, SimpleMortarMesh3D(+rotated), scalar/vector exchange + flux collect, model integration + EC guard, serial+MPI tests |
PR #145 |
| 2 | Octree forest, refinement primitives, uniform refine, EmitMesh, validity tests |
AMR Stages 2/4a/4b |
| 3 | Indicator 3D, transfer plan/solution transfer, Resize/geometry infrastructure, Regrid, controller, unit tests |
AMR Stages 1/3/4 |
| 4 | lineareuler3d_amr_soundwave (+ coarsen-wake) integration tests, example |
#156/#162 |
| 5 | MPI v1: forest-from-decomposed-mesh, flag allgather, gather-then-slice migration, 2-rank tests | Stage 5 |
| 5b | MPI v2: point-to-point migration - windowed apply, PlanWindows/OwnedRun/ExchangeOldWindow, MIGRATE_GATHER fallback and MIGRATE_VERIFY diagnostic, 2- and 4-rank tests |
Stage-5 v2 / #167 |
| 6 | GPU: mortar/indicator/transfer kernels, device staging, geometry upload paths | Stage 6 |
Delivery status: stages 1–3 and 5 landed with #164, together with the
lineareuler3d_amr_soundwave serial and 2-rank integration tests of stage 4.
Stage 6's mortar and indicator kernels landed with #164; the device-resident
solution transfer and its staging landed with #165 (TransferSolution_3D_gpu,
StageSolutionForTransfer_DGModel3D, ApplyTransferPlan_DGModel3D), which also
delivered stage 4's example, linear_euler3d_amr_spherical_soundwave.
Stage 5b landed with #167, in both dimensions.
Still outstanding: the 3D coarsen-wake regression (the analogue of
lineareuler2d_amr_coarsen_wake); the 3D analogues of 2D Stages 6b/6c
(amortized high-water-mark storage and geometry reuse) to the extent they are
not already inherited from the shared data classes; the device transfer on more
than one rank (the migrated window lands in host memory, so a device-side window
buffer and an old-element offset in the kernel are what remain); and the
distributed forest, which is #167's Stage 2 and is deliberately deferred until
measurement justifies it.
Stages are landed as separate reviewable commits on one PR (or stacked PRs at maintainer preference), each leaving the suite green.
8. Compliance with repository constraints (CLAUDE.md)#
Identical posture to the 2D work, which established the precedents:
- The mortar interface discretization is the same formulation extension
already approved and merged in 2D (#145), extended dimensionally.
- Conforming meshes execute byte-identical code paths (nMortars > 0
gates); all existing 3D regression tests must pass bitwise.
- No collectives inside time stepping; adaptation runs between steps.
- No module renames/moves; additive public API only; Fortran 2008;
no external dependencies (octree is home-grown); fprettify formatting.
- EC split-form 3D models excluded from nonconforming meshes (guarded).