Heat3D — single-purpose drivers¶
MPI drivers for the 3D heat equation \(\partial u/\partial t = D \nabla^2 u\) on a uniform periodic brick \([0,N)^3\) with spacing 1 (CPU, plus HIP twins for compact FD and implicit-Euler spectral). Each method ships as its own executable, and the binaries are arranged as a model hierarchy from “raw pointers in main” to “compose three production stacks”:
Binary |
Layer |
What it shows |
|---|---|---|
|
Version 0 — from scratch |
The bottom of the hierarchy. No |
|
Laboratory-style FD (2nd order, 7-point) |
The same physics, lifted one level up. The driver still spells out the stencil and the explicit Euler update, but it now uses |
|
Compact FD (orders 2–20) |
The production path. Two |
|
Compact FD on HIP |
Same physics and CLI as |
|
Point-wise spectral RHS + explicit Euler |
The spectral twin of |
|
Implicit Euler in Fourier space |
HeFFTe-backed forward + backward FFT per step ( |
|
Implicit Euler on HIP |
HIP twin of |
|
Verification, not a physics demo |
Not part of the model hierarchy above: sweeps |
|
Verification + a method recommendation |
Also not part of the hierarchy: answers which spatial operator is the cheaper route to a given accuracy, as a function of how much of Nyquist the field’s content reaches — see Spectral against finite difference: where each wins below. |
All three CPU FD drivers (scratch, manual, fd) compute the same thing with the same 7-point central stencil. Tests assert l2_scratch == l2_manual == l2_compact to within 1e-7. The higher binaries share HeatModel for physics and heat3d::report for the method / timing / l2_error summary line; heat3d_fd_scratch only uses heat3d::report and inlines its own physics.
Initial condition matches the diffusion examples: \(u(\mathbf{x},0)=\exp(-|\mathbf{x}|^2/(4D))\) with origin at \((0,0,0)\). The diffusion coefficient \(D\) is hard-pinned to 1.0 via heat3d::kD in include/heat3d/heat_model.hpp so all heat3d binaries share one fixed value (and their L2 outputs stay directly comparable). To experiment with a different coefficient, change the literal there and rebuild.
Problem setup¶
The Problem setup block the report contract (#112) asks every application
to carry. There is no science preset here and no JSON: this application is
entirely a verification and benchmarking instrument, and no configuration of
it is a production science case.
Item |
Description |
|---|---|
Use case |
Method comparison on one fixed PDE: several finite-difference orders and two spectral treatments of the same diffusion problem |
Question |
For one fixed problem, how do a stencil of order |
Domain |
Uniform periodic brick |
Grid/time |
|
Boundary conditions |
Periodic on all three axes. |
Initial condition |
`u(x,0) = exp(- |
Key physical parameters |
|
Observable |
|
Model maturity |
numerical verification: analytical – every driver is compared against the closed-form Gaussian, and the implicit-Euler symbol, the constant-field fixed point and the reference solution each have their own closed-form checks. Physical completeness: canonical – this is the heat equation, with nothing left out. Calibration: none, by design |
Why periodic, and the reference mismatch¶
The domain is periodic but the reference is the infinite-domain Gaussian,
which never wraps. For a localized bump the mismatch is small – at D = 1 the
profile is already below 1e-7 eight cells from its peak – but it is not
zero, and it contributes to the reported error even when the numerics are
perfectly consistent. The reported number is therefore an upper bound on
discretization error, not a clean measurement of it. That is acceptable because
every driver carries the same offset, which is exactly why D is pinned and
the grid is identical across binaries.
What is not verified¶
No test performs a grid-refinement study or fits an observed convergence order.
The only order-related check asserts that the L2 error at fd_order 8 and 10
is below the second-order value on the same grid – monotonicity, not an
observed-order match to the design order.
Build¶
Enabled with OpenPFC_BUILD_APPS=ON (default). Requires HeFFTe (the spectral binaries use the same FFT stack as the library). When CMake finds OpenMP for C++, the CPU binaries link it so the FD paths can use hybrid MPI + OpenMP: the compact driver’s interior loop runs under a single omp parallel for collapse(2) inside pfc::field::for_each_interior; the manual driver opts into the same parallelisation explicitly via pfc::field::for_each_inner_omp and pfc::field::for_each_owned_omp; the from-scratch driver runs serial (it stays single-threaded on purpose so the bare loops do not need OMP-aware indexing). Set OMP_NUM_THREADS to control the thread count for the compact and manual drivers.
Source layout¶
Shared physics, IC, propagator, parser, and reporting headers (live in include/heat3d/):
include/heat3d/heat_model.hpp— the physics, all in one file: theheat3d::HeatGradsper-point grads aggregate (xx, yy, zz) drawn from the OpenPFC catalog so the kernel’s templated evaluators (pfc::field::FDGradient<G>,pfc::field::SpectralGradient<G>) compute exactly those second derivatives and nothing else; the single source-level constexprheat3d::kDfor the diffusion coefficient; the small self-containedheat3d::HeatModelstruct (initial-condition lambda + optional boundary-value provider + per-point RHSkD * (g.xx + g.yy + g.zz)). OpenPFC-free (only<cmath>and<functional>are included); this is the only file a physicist edits to define a new heat problem or changeD. Seedocs/extending_openpfc/per_point_grads.mdfor the per-point grads contract.include/heat3d/spectral_heat_propagator.hpp— heat-specific implicit-Euler-in-Fourier-space propagator.fill_implicit_euler_symbolbuilds the1 / (1 - dt·D·k²)table (Catch2-tested without a GPU). The CPU class exposesstep(pfc::data::Field<double, pfc::HostSpace>&)onpfc::fft::IHostFFT.include/heat3d/spectral_heat_propagator_hip.hpp— HIP twin: uploads that table once, then each step is a device forward FFT,multiply_complex_real_hip_impl, and inverse FFT.include/heat3d/cli.hpp—RunConfigplus the slim per-binary parsersparse_fd/parse_spectraland their_or_print_usagewrappers.Dis not a CLI knob (it lives inheat_model.hpp). Each binary already knows its own discretisation, so the parsers do not consume anargv[1]discriminator. Header-only, MPI-free, OpenPFC-free; trivially unit-testable.include/heat3d/reporting.hpp—analytic_gaussian(closed-form reference solution on \(\mathbb{R}^3\)),fd_extra_metadata(FD/OpenMP info string), and the rank-0reporttemplate that prints the canonicalmethod/timing/l2_errortriplet, shared by all heat3d binaries.include/heat3d/convergence_study.hpp— the Order of accuracy (FD) study’s sharedrun_case(fd_order, N): single-Fourier-mode IC,pfc::gradient::FDGradient<HeatGrads>bound the same wayheat3d_fd.cppbinds it, and an exact (not time-marched) closed-form evolution to isolate spatial from temporal error. Used by bothheat3d_fd_convergence_studyandtests/test_heat3d_fd_convergence.cpp.include/heat3d/spectral_content_study.hpp— Heat3D driver aroundperiodic_spectra.hppandfd_symbols.hpp: Gaussian RK4 validation of the shipped FD stack, plus occupancy-matched family maps. Used byheat3d_spectral_content_studyandtests/test_heat3d_spectral_content.cpp.
Per-binary drivers (live in src/cpu/):
src/cpu/heat3d_fd_scratch.cpp— version 0, the from-scratch driver. Inlines the ICexp(-r²/(4 D))and the Laplacian; runs three nestedfor (k) for (j) for (i)loops with manual paddedlin = (i+hw)*sx + (j+hw)*sy + (k+hw)*szand stencil neighboursu_ptr[lin ± sx/sy/sz]; per-step Laplacian held in astd::vector<double>of sizenx*ny*nzwith no halo (visibly different flat indexing from the paddedu). The only OpenPFC pieces in the hot loop arepfc::comm::HaloExchange::exchange(), plus a padded field ctor for storage anddomain::create / decomposition::createfor geometry. Read this one first.src/cpu/heat3d_fd.cpp— compact FD driver. After constructing twopfc::data::Field<double, HostSpace>fields (u,du) pluspfc::comm::HaloExchange<HostSpace, double> halo(u, decomp, rank, MPI_COMM_WORLD)andpfc::gradient::FDGradient<HeatGrads> grad(u, cfg.fd_order), the hot loop reads:for (int step = 0; step < cfg.n_steps; ++step) { halo.exchange(); pfc::field::for_each(du, [&](const auto& idx) { const auto g = pfc::gradient::evaluate(grad, idx); du[idx] = g.xx + g.yy + g.zz; // D = 1 in this driver }); u += cfg.dt * du; // explicit Euler, on the page t += cfg.dt; }
Halo, gradient evaluator, and per-cell sweep are three visible primitives composed by the user.
halo.exchange()is the blocking one-shot;pfc::gradient::evaluate(grad, idx)keeps the inner loop dimension-agnostic;pfc::data::Field::operator[]is used for element access. The spectral twin still bundles those concerns throughpfc::sim::DuField.src/hip/heat3d_fd_hip.cpp— HIP twin of compact FD.FDGPUStack<HIPSpace>owns the padded device field andHaloExchange<HIPSpace>. Default step on more than one rank (HEAT3D_HALO_OVERLAP=1) callspfc::comm::GpuHaloOverlap(Waitall): interior on a non-blocking compute stream, Faces pack/MPI on the default stream, thenfinish()+ one-launch boundary shell. A single rank has no MPI halo, so the default falls back to blocking;=0forces blocking;=2isTestall(MPI_Testallpump). Built whenOpenPFC_ENABLE_HIPis on.src/hip/heat3d_spectral_hip.cpp— HIP twin of implicit-Euler spectral.HIPSpectralStack+SpectralHeatPropagatorHIP. Built whenOpenPFC_ENABLE_HIP_SPECTRALis on. CTestheat3d-spectral-hip-smokelooks forHEAT3D_SPECTRAL_HIP_CHECKSUM. LUMI submit:docs/lumi_slurm/submit_heat3d_spectral_hip_scaling.sh.src/cpu/heat3d_fd_manual.cpp— laboratory-style 2nd-order central FD driver. The hot loop reads:halo.start_halo_exchange(u.data(), u.size()); field::for_each_inner_omp(u, hw, stencil_step); // overlap halo.finish_halo_exchange(); field::for_each_border (u, hw, stencil_step); // touches halos field::for_each_owned_omp(u, [&](int i, int j, int k) { u(i, j, k) += cfg.dt * du(i, j, k); // explicit Euler });
with a single
stencil_steplambda callingmodel.rhs(0.0, HeatGrads{xx, yy, zz}). Each stage is wrapped inpfc::runtime::tic(timer, "...")/toc(timer, "...")andprint_timing_summary(timer, 0)prints a sorted breakdown on rank 0 at the end.src/cpu/heat3d_spectral.cpp— implicit-Euler spectral driver. Callsheat3d::SpectralHeatPropagator::step(stack.u())once per step (forward FFT → diagonal multiply in k-space → inverse FFT). HIP twin:heat3d_spectral_hip.src/cpu/heat3d_spectral_pointwise.cpp— point-wise spectral RHS: the spectral twin ofheat3d_fd. Built onpfc::sim::stacks::SpectralCPUStack+pfc::sim::DuField<HeatGrads, SpectralGradient<HeatGrads>>, so the user-facing time loop readsdu.apply(...)/u += dt * du/t += dt; the residual is materialised bypfc::field::SpectralGradient<HeatGrads>(1 forward + 3 inverse FFTs/step) instead of a stencil sweep. TheDuFieldshim hides halo prep + per-cell evaluation here, where the FD twin (heat3d_fd) instead spells those primitives out inmain.src/cpu/heat3d_fd_convergence_study.cpp— the Order of accuracy (FD) sweep: loopsheat3d::convergence::run_case(fd_order, N)over every(fd_order, N)pair, prints the design-vs-observed-order table, and writesout/report/data/heat3d_fd_order_convergence.csv.src/cpu/heat3d_spectral_content_study.cpp— the Spectral against finite difference study: prints the error map, the crossover fractions and the equal-accuracy cost ladder, runs the validation cases, and writesout/report/data/heat3d_spectral_content_{map,crossover,validation}.csv.
Tests:
tests/test_heat3d.cpp— Catch2 unit tests covering:heat3d::kDis pinned to 1.0;HeatModel(default IC at the origin, IC override, therhs = kD * (xx + yy + zz)formula); the slim per-binary CLI parsers (happy paths + every rejection case);fill_implicit_euler_symbol(DC = 1, first-mode formula, wrapped \(k_y\), size mismatch) and a constant-field fixed point ofSpectralHeatPropagator; the analytic reference solution; two single-rank integration tests against a padded field / explicit Euler stepper; two single-rank cases for the manual driver (smoke + L2-vs-analytic, and parity vs the compactFDCPUStackpath); and two single-rank cases for the from-scratch driver (smoke + L2-vs-analytic, and parity vs the compact path to within 1e-7). Built into thetest_heat3dexecutable and registered with CTest asheat3d-all-testswheneverOpenPFC_BUILD_TESTS=ONand Catch2 is available (setHEAT3D_ENABLE_TESTS=OFFto skip). HIP smokes:heat3d-fd-hip-smoke(production overlap),heat3d-fd-hip-blocking-smoke,heat3d-spectral-hip-smoke.tests/test_heat3d_fd_convergence.cpp— regression guard for Order of accuracy (FD): asserts the observed order of accuracy atfd_order4 and 8 matches the design order within a stated tolerance. Registered asheat3d-fd-convergence; pure FD (no HeFFTe needed).
Where OpenMP runs (FD)¶
In heat3d_fd.cpp the per-step sweep over du is delegated to pfc::field::for_each (include/openpfc/kernel/field/brick_iteration.hpp), which iterates the brick’s owned cells in cache-friendly k-outer / j-middle / i-inner order and yields a pfc::Int3 to the user lambda. Each cell evaluates pfc::gradient::evaluate(grad, idx) against pfc::gradient::FDGradient<HeatGrads> (per-axis stencil application from kernel/field/fd_apply.hpp), writes the per-point residual into du[idx], and the explicit-Euler step u += dt * du then updates the field in place.
In heat3d_fd_manual.cpp the same parallelisation is applied explicitly: pfc::field::for_each_inner_omp parallelises the inner-region stencil over (k, j), pfc::field::for_each_owned_omp parallelises the Euler update; the (cheap) border pass stays serial. Not parallel in either driver: the MPI halo exchange (pfc::comm::SparseExchange on separated-layout stacks elsewhere; pfc::comm::HaloExchange in these heat3d FD binaries).
So htop/top can look single-threaded when wall time is dominated by MPI (many ranks, thin subdomains), or when the launcher has pinned the process to a narrow CPU mask (OpenMP then schedules all threads inside that mask).
Single MPI rank on Linux: heat3d calls pfc::runtime::reset_cpu_affinity_if_single_mpi_rank (which uses sched_setaffinity) after MPI_Init to allow all online logical CPUs for that process, so OpenMP can use the machine without extra mpirun flags. Set OPENPFC_NO_RESET_AFFINITY (any value) to keep the launcher’s mask unchanged (e.g. if your site policy forbids overriding binding).
Several MPI ranks on one node: the reset is not applied (MPI_COMM_WORLD size \(>1\)); use launcher options so each rank gets a disjoint CPU set and set OMP_NUM_THREADS per rank accordingly, for example:
export OMP_NUM_THREADS=4
mpirun --bind-to none -n 4 ./apps/heat3d/heat3d_fd 256 25 1e-6 12
# or: export OMPI_MCA_hwloc_base_binding_policy=none
Rank 0 prints omp_max_threads and omp_get_num_procs() in the summary line. After a single-rank run, omp_get_num_procs() should match the number of CPUs OpenMP may use; if it stays at 1, check binding and whether OPENPFC_NO_RESET_AFFINITY is set.
Usage¶
heat3d_fd_scratch <N> <n_steps> <dt>
heat3d_fd_manual <N> <n_steps> <dt>
heat3d_fd <N> <n_steps> <dt> <fd_order>
heat3d_fd_hip <N> <n_steps> <dt> <fd_order>
heat3d_spectral_pointwise <N> <n_steps> <dt>
heat3d_spectral <N> <n_steps> <dt>
heat3d_spectral_hip <N> <n_steps> <dt>
heat3d_fd_convergence_study [output.csv]
heat3d_spectral_content_study [--data-dir DIR] [--no-validate|--held-out-only|--held-out-families]
Dis not a CLI knob: it is fixed atheat3d::kD = 1.0ininclude/heat3d/heat_model.hpp. Edit the literal there if you want to experiment.fd_order(compact FD only): even integers 2 through 20. Halo width isfd_order/2per face. Each local subdomain must be wider thanfd_orderin every dimension (the driver aborts withMPI_Abortif the interior slab is empty).The scratch and manual drivers hard-code 2nd-order central (halo width 1) — extend the inline stencil /
stencil_steplambda if you want a different order; the goal is to keep the showcase code uncluttered.Stability (FD): explicit Euler requires a sufficiently small
dt(CFL); higher spatial order does not remove this limit.Spectral: each step applies \(\hat u \leftarrow \hat u / (1 + \Delta t\,D|\mathbf{k}|^2)\) in complex Fourier space (implicit Euler for the linear diffusion operator).
Example¶
From your build directory:
mpirun -n 4 ./apps/heat3d/heat3d_fd_scratch 64 200 0.001
mpirun -n 4 ./apps/heat3d/heat3d_fd_manual 64 200 0.001
mpirun -n 4 ./apps/heat3d/heat3d_fd 64 200 0.001 4
mpirun -n 4 ./apps/heat3d/heat3d_spectral_pointwise 64 200 0.001
mpirun -n 4 ./apps/heat3d/heat3d_spectral 64 200 0.001
# HIP (one rank per GPU; LUMI-G uses the submit helpers under docs/lumi_slurm/):
# heat3d_fd_hip 64 200 0.001 2
# heat3d_spectral_hip 64 200 0.001
All three FD binaries report identical l2_error_vs_R3_analytic_rms (the manual binary additionally prints a per-section breakdown — inner / halo_wait / border / euler — from pfc::runtime::print_timing_summary).
Rank 0 prints timing_s, avg_step_time_s (MPI max across ranks), omp_max_threads when OpenMP is enabled, and an RMS L2 error against the infinite-domain Gaussian reference
The domain is periodic; for a localized Gaussian the mismatch at boundaries contributes to the reported error even when the numerics are consistent.
Benchmarks (FD)¶
All numbers below are avg_step_time_s from rank 0 (same as MPI_MAX across ranks for these runs). Build: Release, GCC 11.2.0, Open MPI 5.0.10, HeFFTe CPU prefix on CMAKE_PREFIX_PATH. Host: login node used for development (timing varies with load; repeat for publication-quality data).
Higher-order FD and MPI (OMP_NUM_THREADS=1)¶
Fixed case: N=256, n_steps=25, dt=1e-6 (D = heat3d::kD = 1.0). Higher orders use wider stencils (more flops per step) but still scale down with rank count on this grid.
fd_order |
ranks=1 |
ranks=2 |
ranks=4 |
ranks=8 |
|---|---|---|---|---|
8 |
0.651413 |
0.313773 |
0.156707 |
0.0852177 |
10 |
0.920717 |
0.460287 |
0.198479 |
0.100299 |
12 |
0.972677 |
0.542606 |
0.264795 |
0.155862 |
14 |
1.03012 |
0.58818 |
0.310875 |
0.151631 |
16 |
1.21346 |
0.617872 |
0.331 |
0.164514 |
18 |
1.42205 |
0.682012 |
0.36659 |
0.18127 |
20 |
1.51928 |
0.839491 |
0.474414 |
0.190416 |
Reproduce (after module load gcc/11.2.0 openmpi/5.0.10 and configuring with that toolchain):
export OMP_NUM_THREADS=1
mpirun -n 8 --mca btl tcp,self --mca oob tcp ./apps/heat3d/heat3d_fd 256 25 1e-6 20
Sweep example:
export OMP_NUM_THREADS=1
for ranks in 1 2 4 8; do
for ord in 8 10 12 14 16 18 20; do
mpirun -n "$ranks" --mca btl tcp,self --mca oob tcp ./apps/heat3d/heat3d_fd 256 25 1e-6 "$ord"
done
done
OpenMP scaling (fd_order=12, one MPI rank)¶
Same grid 256³, n_steps=25, dt=1e-6, mpirun -n 1 (D = heat3d::kD = 1.0).
Correctness: For this case, l2_error_vs_R3_analytic_rms is identical for OMP_NUM_THREADS in {1,2,4,8} (bit-for-bit in repeated runs), so the OpenMP region is not changing the numerics—only scheduling.
Scaling (2026-04-29, shared login node): Real FD run, mpirun -n 1, after the in-process single-rank CPU mask reset on Linux so omp_get_num_procs() matches the node (here 32). l2_error_vs_R3_analytic_rms=9.91404e-21 was identical for \(\{1,2,4,8\}\) threads. Speedup is sub-linear (memory traffic per step + fork/join each step), but 8 threads \(\approx\) 5.3\(\times\) faster than 1 thread on this capture—consistent with a healthy OpenMP Laplacian loop rather than a pinned single-core mask.
OMP_NUM_THREADS |
avg_step_time_s |
|---|---|
1 |
2.86396 |
2 |
1.53138 |
4 |
0.883718 |
8 |
0.535633 |
for t in 1 2 4 8; do
export OMP_NUM_THREADS=$t
mpirun -n 1 --mca btl tcp,self --mca oob tcp ./apps/heat3d/heat3d_fd 256 25 1e-6 12
done
Hybrid MPI + OpenMP (FD)¶
Use fewer MPI ranks and OMP_NUM_THREADS matching usable cores so halo exchange stays smaller while threads parallelize over interior \((i_y,i_z)\) lines (the omp parallel for collapse(2) lives in pfc::field::for_each_interior for the compact driver and in pfc::field::for_each_inner_omp / for_each_owned_omp for the manual driver).
export OMP_NUM_THREADS=8
export OMP_PROC_BIND=close # optional; site-dependent
mpirun --bind-to none -n 4 --mca btl tcp,self ./apps/heat3d/heat3d_fd 256 25 1e-6 12
The spectral binaries are unchanged (MPI over ranks only).
Order of accuracy (FD)¶
heat3d_fd ships central FD stencils at every even order 2–20, but until
now nothing measured whether raising the order actually buys accuracy.
heat3d_fd_convergence_study does: for each even fd_order in {2, 4, 6,
8, 10, 12} it sweeps N in {16, 24, 32, 48, 64} on a periodic domain of
length \(2\pi\) with the single-Fourier-mode initial condition
\(u_0(x,y,z) = \cos(3x)\), whose exact PDE solution is
\(u(x,t) = e^{-9Dt}\cos(3x)\) — chosen (instead of the Gaussian IC the
other heat3d binaries use) because it is periodic to machine precision at
every grid resolution, so there is no domain-truncation error competing
with the stencil error being measured.
How temporal error was kept out of the fit. Two designs were tried and rejected before the one below, because both reproduced the textbook failure mode this kind of study is warned about — “the curves all flatten at the same floor, and you are measuring the time integrator, not the stencil”:
Explicit Euler with
dt = dt_safety * dx^2 / D(the standard parabolic-CFL scaling, re-derived at each case’s owndx). Euler’sO(dt)global temporal error then scales asdx^2— the same power as the plain 7-point stencil’s own design order — so fd_order 4/6/8/10/12 all flattened onto one commondx^2floor at fine grids instead of showing their own steeper slopes.Explicit Euler with one fixed
dtfor the whole sweep (independent ofdx, sized from the finest grid’s stability limit). This removes the floor’sdx^2growth at coarse grids, but the floor itself (measured: ≈1.8e-6, matching the predicted~0.5·D²·m⁴·t·dt·e^{-Dm²t}for thatdt) was still far above round-off, and reaching a round-off-level floor this way needs adtsome 5 orders of magnitude smaller — i.e.n_steps = t_final/dtabout 10⁵× larger, since onedtis shared by every case. That is minutes turning into most of a day for no benefit: a fixed, non-vanishingdtwas always going to leave some floor.
What is used instead: no time-stepping loop at all. cos(3x) is an
exact eigenfunction of pfc::gradient::FDGradient<HeatGrads>’s stencil
under periodic wraparound — any linear, shift-invariant stencil is
diagonalized by the discrete Fourier basis (a standard circulant-matrix
fact) — so Lap_FD[u0] = -k_eff² · u0 for one k_eff shared by every grid
point. The study verifies this numerically rather than assuming it: the
measured eigenvalue_residual (RMS of Lap_FD[u0] - eigenvalue·u0,
relative to u0) is ~1e-12 to 1e-14 in every case, i.e. round-off, not
model error. Because u stays exactly proportional to cos(3x) for all
time under the (spatially discretized, not yet time discretized) ODE, that
ODE has a closed form, u(x,t) = u0(x)·exp(D·eigenvalue·t), evaluated
directly with zero time-discretization error — not “small”, exactly
zero, by construction. What is left after comparing against the true PDE
solution is purely the stencil’s spatial truncation error. (This mirrors
tests/integration/scenarios/time_integration/test_rk3_convergence.cpp in
the opposite direction: that test replaces the spatial operator by its
exact eigenvalue action to cleanly isolate a temporal order; this study
replaces the time evolution by its exact action to cleanly isolate a
spatial order.) See apps/heat3d/include/heat3d/convergence_study.hpp
for the full derivation and code.
A cross-check against real time-stepping. Before discovering that a
fixed dt cannot cheaply reach round-off, design 2 above was run to
completion. Away from its own floor its numbers agree closely with the
exact-evolution numbers below — e.g. fd_order 4, N=32: 2.6146e-4 (Euler,
dt≈1.93e-6, 25939 steps) vs 2.6321e-4 (exact evolution) — a 0.7%
difference consistent with Euler’s own residual O(dt) error at that
dt, confirming the exact-evolution shortcut is measuring the same thing
real time-stepping would, just without paying for the steps.
Measured table (LUMI, CPU, Release; out/report/data/heat3d_fd_order_convergence.csv
has the full per-N breakdown):
fd_order (design) |
L2 error, N=16 |
L2 error, N=64 |
observed order (range across N) |
|---|---|---|---|
2 |
2.297e-02 |
1.465e-03 |
1.97 – 2.00 |
4 |
3.854e-03 |
1.683e-05 |
3.84 – 3.98 |
6 |
7.817e-04 |
2.333e-07 |
5.71 – 5.96 |
8 |
1.745e-04 |
3.576e-09 |
7.57 – 7.95 |
10 |
4.132e-05 |
5.820e-11 |
9.43 – 9.94 |
12 |
1.019e-05 |
1.013e-12 |
11.28 – 11.84 |
Every design order is reproduced within a few tenths of a unit, tightening
towards the design value as N grows (finite-N sub-leading truncation
terms, not a defect). fd_order 12 at N=64 sits on the double-precision
round-off floor: its L2 error (1.013e-12) is within an order of
magnitude of the measured eigenvalue_residual there (8.95e-13), so the
last pairwise ratio for fd_order 12 is measuring floating-point noise as
much as the stencil — expected and reported rather than hidden, per the
usual caveat for high-order stencils on fine grids. fd_order 10 stays
clean (well above its own floor) across the whole N range tested.
test_heat3d_fd_convergence.cpp pins fd_order 4 (measured avg ≈3.92) and
fd_order 8 (measured avg ≈7.80) as a regression guard, using all five grid
sizes since neither gets near the round-off floor in this range.
Figure: out/report/figures/heat3d_fd_order_convergence.svg (log-log
L2 error vs dx, one line per order, dotted reference slopes for orders 2,
8, 12, and the round-off floor marked). Regenerate after re-running the
study with:
./apps/heat3d/heat3d_fd_convergence_study out/report/data/heat3d_fd_order_convergence.csv
python3 out/report/figures/make_figures.py # needs matplotlib; see that file's docstring
A boundary-shell bug found while building this study (worth knowing if
you use the FDCPUStack + pfc::sim::steppers::create pattern
elsewhere): the first version of this study reused the same construction
as test_heat3d.cpp’s Gaussian heat3d_gaussian_l2_rms helper —
FDCPUStack + pfc::field::create<G>(stack.u(), order) +
pfc::sim::steppers::create. FDCPUStack::u() is an unpadded Field
(storage_halo() == 0); binding FDGradient to it via the raw-pointer
constructor restricts the evaluator to [fd_order/2, N-fd_order/2) per
axis, and pfc::sim::for_each_interior (what the steppers::create
factory uses) leaves everything outside that interior untouched in du —
a boundary shell of width fd_order/2 per face is silently never
updated by the Euler step, for every N and every fd_order.
FDCPUStack::du<G>() covers that shell separately through its face-halo
buffers, but nothing does if the evaluator + stepper are built and driven
directly, which is exactly what the existing Gaussian helper (and this
study’s first draft) does. A Gaussian IC decays to ~0 near the domain
edge, so the frozen shell is invisible in its L2 error; a periodic
single-mode IC has full amplitude at the edge, so the same construction
produced a flat ~3–6% error with no grid-resolution dependence at all —
immediately visible, and the tell that something structural (not a tuning
problem) was wrong. The fix used here is to match heat3d_fd.cpp’s own
construction instead (padded Field + HaloExchange + FDGradient bound
to the padded field, whose stencil covers the whole owned domain) — see
apps/heat3d/include/heat3d/convergence_study.hpp. Anyone else composing
FDCPUStack with pfc::sim::steppers::create directly (rather than via
FDCPUStack::du<G>()) should check for the same gap.
Spectral against finite difference: where each wins¶
The order sweep above is the single-mode accuracy measurement, and one
smooth mode is the most favourable case a high-order stencil can be handed.
It says nothing about a field carrying real content near the grid scale, so
on its own it cannot decide between the FD and spectral paths.
heat3d_spectral_content_study closes that, and the derivation lives in
include/heat3d/spectral_content_study.hpp.
The idea. The heat equation is linear on a periodic box, so every
operator is diagonal in the Fourier basis and each mode decays independently:
exactly as exp(-D|k|^2 t), and under FD as exp(D*lambda_p(k)*t) with the
stencil’s own symbol. The spectral operator uses -|k|^2 itself, so its
spatial error is identically zero. By Parseval the L2 error of any
initial field is therefore a closed-form sum over that field’s spectrum — no
simulation required to draw the map.
The parameter. Occupancy f is the fraction of Nyquist at which a
family’s amplitude prescription reaches 1e-3 of its peak (a top-hat uses
the same f as its support cutoff). The original map used a periodic
Gaussian, which is one family in
periodic_spectra.hpp.
The Gaussian map depends on f alone, not on the grid — pinned by
tests/test_heat3d_spectral_content.cpp and
tests/unit/kernel/field/test_periodic_spectra.cpp. Other families can
share f while placing different energy near high-error stencil modes;
heat3d_spectral_content_study writes that comparison without a new
timing campaign.
The result. Under an N^3 cost model at a fixed step count, FD order p
is the cheaper route to accuracy eps iff the coarsest grid it can use still
keeps the content above cbrt(cost_fd / cost_spectral) of Nyquist. With the
measured per-step costs (out/report/data/heat3d_method_cost.csv) that
threshold is 0.32 (FD-2) to 0.49 (FD-12): a 32x cheaper step buys only
32^(1/3) = 3.2x in grid spacing. The crossover in accuracy is at
L2 ~ 6e-7, owned by FD-12; below 1e-2 FD-2 costs a twentieth of the
spectral path. The full tables are in the
applications catalog
(Heat3D comparison; formerly out/report/16_scalability.qmd).
Validated, not asserted. Ten points of the map were re-measured by
running the shipped FD stack (padded Field + HaloExchange +
FDGradient<HeatGrads>) under RK4 against the exact solution of the same
sampled field. Prediction and measurement agree to between eight and ten
significant figures.
One implementation note worth stealing. The map needs the stencil’s
dispersion defect theta^2 + lambda_p*h^2, which for order 12 at
theta = 0.1 is 1.2e-19 while the two terms being subtracted are 1e-2:
computed directly it is pure round-off. The way out is that the order-2M
central second-difference stencil is exactly the series
(2 asin(delta/2))^2 = sum_m a_m delta^(2m) truncated at m = M, with
delta^2 = 2 - 2cos(theta) and a_m = 2/(m^2 * C(2m,m)). The defect is then
the tail of that series — all-positive terms, no cancellation at all. The
test pins the identity against the shipped EvenCentralD2 tables, so a
future edit to fd_stencils.hpp cannot break it silently.
Reproduce (single rank, login node, no allocation needed):
./apps/heat3d/heat3d_spectral_content_study --no-validate
./apps/heat3d/heat3d_spectral_content_study --families-only
./apps/heat3d/heat3d_spectral_content_study --validate-families --no-validate --no-families
# Held-out family RK4 (compute node; cosine-sum ICs). The driver writes
# the CSV then exits nonzero if max |ratio-1| is not below the frozen 1e-6:
# sbatch apps/heat3d/slurm/heldout_families.sbatch
python3 out/report/figures/make_figures.py # needs matplotlib
Figure: out/report/figures/heat3d_spectral_content.svg.
See also¶
examples/heat/separated-halos/model.cpp— FD + halos patternexamples/heat/gaussian-pulse/model.cpp— spectral diffusion IC and operator