High-Order Matrix-Free Operator
Experimental in v0.1. The drivers below check the operator apply on one GPU and on several ranks, but MARS v0.1 has no linear solver that uses it. See Known limitations.
This page explains what the high-order (HO) matrix-free operator computes, how to build and run the two drivers that test it, and how to read their output.
What the operator is
The operator is the control-volume finite element (CVFEM) form of the scalar diffusion
operator -div(grad u) on hexahedral meshes, for polynomial orders p = 1 to 8. It follows
the high-order CVFEM method of Knaus (2022), Algorithm 2.
Matrix-free means that MARS computes y = A x without storing the matrix A. One call is one
operator application, a matrix-vector product (matvec). It is not a linear solve: v0.1 has
no Krylov method or preconditioner for this operator. The operator is not symmetric for
p >= 2, so plain CG does not apply to it; the host accuracy study below uses a dense LU solve
for this reason.
The operator is diffusion only. It has no advection term, and it is not the Navier–Stokes solver
of the Poiseuille and Taylor–Green
tutorials, which uses linear (p = 1) elements and assembled matrices.
How one apply works
An element of order p has (p+1)^3 nodes on Gauss–Lobatto–Legendre points. Each node is a
degree of freedom (DOF); nodes on element faces, edges and corners are shared with neighbours.
- Reference operators. Four small 1D matrices (
Btil,Dtil,D,W) depend only onp.buildHoCvfemOperators(fem/mars_cvfem_ho_basis.hpp) builds them once; the GPU keeps them in constant memory. - Geometry. Each element enters through a metric
detJ J^-1 J^-Tat the points of its sub-control surfaces:9 p (p+1)^2doubles per element (d_G). It is computed once from the 8 corner coordinates and reused by every apply. A straight-sided hex of any shape works; the cross terms of the metric carry the shear. - Sum factorization. The element apply is a sequence of 1D contractions along x, y and z, so
its cost per element grows like
p^4, not likep^6as for a dense(p+1)^3 x (p+1)^3element matrix. - Gather, apply, scatter. For each element the kernel reads its values of
x, applies the operator and adds the result intoywithatomicAdd.ymust be zero before the call.
File (under backend/distributed/unstructured/) |
Content |
|---|---|
fem/mars_cvfem_ho_basis.hpp |
the 1D reference operators |
fem/mars_cvfem_ho_apply.hpp |
host reference: computeElementMetric, applyHoCvfemElement |
fem/mars_cvfem_ho_matfree.hpp |
GPU metric and apply: ho_cvfem_metric_perpoint_launch, ho_cvfem_apply_launch |
fem/mars_cvfem_ho_matfree_shfl.hpp |
register and warp-shuffle variant of the apply |
fem/mars_ho_dof_handler.hpp, fem/mars_ho_dof_handler_gpu.hpp |
DOF numbering (host reference, device) |
fem/mars_ho_halo.hpp |
ownership of shared DOFs and the DOF halo HoHalo |
On several ranks three more pieces are needed:
- Numbering.
buildDistributedGpunumbers the DOFs on the device. A DOF on an edge or face is named by the sorted global ids of the entity's corners plus its position inside the entity (DofKey). Every rank that holds the DOF computes the same name, without communication. - Ownership. A corner DOF belongs to the rank that owns the mesh node. An edge or face DOF
belongs to the lowest rank among the ranks that hold it. An element-interior DOF belongs to
the rank of its element. The numbering registers corners only through the elements a rank
owns, so it needs every node owner to own an element at its node. The older vote node
ownership guarantees that; the default SFC ownership does not. The multi-rank drivers select
vote ownership themselves (
MARS_OWNERSHIP=vote, set before the domain is built); code of your own needs the same setting, and the halo stops with a message if it is missing. - Halo. One distributed matvec is
forward(owners copy their values to the ghost copies), the apply over the elements this rank owns, thenreverseAdd(ghost contributions are added to the owners).
Build
The drivers are part of the FEM examples and need CUDA:
cmake -B build -DMARS_ENABLE_CUDA=ON -DMARS_ENABLE_FEM_EXAMPLES=ON -DCMAKE_CUDA_ARCHITECTURES=90
cmake --build build --target mars_cvfem_ho_matfree_test mars_ho_dist_apply_test -j
CMAKE_CUDA_ARCHITECTURES is 90 for H100/GH200 and 80 for A100. The commands below run from the
repository root.
One GPU: mars_cvfem_ho_matfree_test
./build/examples/distributed/unstructured/mars_cvfem_ho_matfree_test
It takes no mesh. For each order p = 1 ... 7 it builds a structured unit cube (from 48^3
elements at p = 1 down to 16^3 at p = 7), numbers the DOFs on the device and runs these
gates:
| Gate | Check | Pass if |
|---|---|---|
| A | GPU metric of one element against computeElementMetric; GPU apply of one element against applyHoCvfemElement |
max error < 1e-12 (relative for the apply) |
| B | A·1: a constant field has no flux |
max |y| < 1e-9 at every DOF |
| C | A·x for the linear field x |
max |y| < 1e-9 at interior DOFs |
| shear | p = 1, 2, 4 on a cube where every element has its own shear: metric and full apply against the host |
metric error < 1e-12, apply < 1e-11 relative |
It then times 100 applies per order. One line per order, for example:
p=1 E=48 nDof=117649 nEl=110592 | metricErr=... elemRel=... | A*1=... A*lin(int)=... | PASS
perf: ... ms/apply | ... MDOF/s | ... GB/s (useful traffic est)
After the shear lines it prints an order-sweep table and the verdict:
HO-CVFEM matrix-free GPU gate: PASS
The exit code is 0 if every gate passes and 1 otherwise. In the sweep table, the column
assembled B/DOF is an estimate with (2p+1)^3 nonzeros on every row. Only rows of mesh
vertices have that many; see Memory for exact counts.
--dof-self-check (or MARS_HO_DOF_SELF_CHECK=1) first compares the device numbering with the
host HODofHandler::build() for p = 1 ... 7. The two numberings differ by a permutation, so the
check compares the DOF counts, the set of DofKeys and which element slots share a DOF.
Several GPUs: mars_ho_dist_apply_test
mpirun -np 4 ./build/examples/distributed/unstructured/mars_ho_dist_apply_test --ncells=16 --p=3
Each rank generates its part of a unit cube with ncells^3 hexes, so no mesh file is needed.
Each rank uses GPU rank % deviceCount. The driver builds the distributed numbering, the
ownership and the halo, and then applies the operator once to x = 1. The result must vanish on
every owned DOF. A wrong pairing across ranks leaves an error of order 1 at the rank
boundaries. The gate runs twice, with the host halo and with the device halo, and then the
driver times 50 matvecs:
p=3 owned DOF=... max|A.1| over owned (host halo, gpu-numbering) = ... [PASS]
p=3 device A.1 = ... [PASS] full matvec ... MDOF/s | apply-only ... MDOF/s | comm ...% [path=STORE-d_G]
THROUGHPUT(p=3): apply-only ... GDOF/s | ... FLOP/DOF | ... GFLOP/s (FP64) | ... DOF/GPU [path=STORE-d_G]
measured: ... ms/matvec (slowest of 4 ranks, 50 iters) | sustained ... TDOF/s | ... global DOF [path=STORE-d_G]
STORAGE (p=3): store-d_G = ... B/DOF | recompute(corners) = ... B/DOF | ...
- A gate passes when the maximum is below 1e-8.
full matvecincludes the halo exchange andapply-onlydoes not;comm = (full - apply) / full. All times are those of the slowest rank.- The driver exits with 0 even when a gate fails, so read the
[PASS]/[FAIL]tags. If a rank runs out of GPU memory, the driver printsDEVICE OOMand stops all ranks.
| Option | Meaning |
|---|---|
--ncells=N |
cube with N^3 elements (default 16) |
--p=P |
order, 1 to 8 (default 2) |
--iters=N |
number of timed matvecs (default 50) |
--irregular |
move interior nodes by up to a quarter of the cell size: distorted hexes and an irregular partition |
--sweep |
run p = 1 ... 8 in one job, with about the same DOF count per GPU (ncells / p per order), and print one table |
--MF or --recompute |
do not store the metric; rebuild it from the corners in every apply |
--host-numbering |
number the DOFs on the host instead of the device |
--self-check |
number on the host and on the device, compare them, then run the gate with the device numbering |
| Environment variable | Effect |
|---|---|
MARS_HO_SHFL=1 |
use the register and warp-shuffle kernel instead of the default kernel |
MARS_HO_FP32_METRIC=1 |
store the metric in single precision; the arithmetic stays double |
MARS_HO_SKIP_HOST_GATE=1 |
skip the host-halo gate; it is skipped anyway above 50M DOF per rank |
MARS_GLOBAL_BUCKETSIZE=N |
bucket size of the cornerstone global tree (see below) |
--MF turns off MARS_HO_FP32_METRIC, and MARS_HO_FP32_METRIC turns off MARS_HO_SHFL.
With Slurm, make sure the variables reach the ranks (srun --export=ALL).
The global octree of cornerstone is replicated on every rank. For large cubes the driver raises
its bucket size so that the reduction of the tree counts stays below 2 GiB, and prints
[build] global bucketSize=...; MARS_GLOBAL_BUCKETSIZE overrides the choice.
Results you can reproduce
Both programs below run on the host (any CPU, no GPU, no MPI) in a few seconds.
Accuracy
g++ -std=c++20 -O2 -I. examples/distributed/unstructured/mars_cvfem_ho_convergence.cpp -o mars_cvfem_ho_convergence
./mars_cvfem_ho_convergence
It solves -div(grad u) = 3 pi^2 u_exact on the unit cube with
u_exact = sin(pi x) sin(pi y) sin(pi z) and u = 0 on the boundary, with the HO CVFEM operator
and a dense LU solve, and prints the relative L2 error against u_exact. Output (Apple clang on
macOS; another compiler can change the last digits):
p=1 : #DOFs | L2 error | rate
125 | 1.6808e-01 | -
343 | 7.8594e-02 | 2.26
729 | 4.5010e-02 | 2.22
2197 | 2.0264e-02 | 2.17
p=2 : #DOFs | L2 error | rate
125 | 4.3029e-02 | -
343 | 1.2109e-02 | 3.77
729 | 4.9921e-03 | 3.53
2197 | 1.4524e-03 | 3.36
p=3 : #DOFs | L2 error | rate
343 | 3.5038e-03 | -
1000 | 6.9237e-04 | 4.55
2197 | 2.1899e-04 | 4.39
p=4 : #DOFs | L2 error | rate
125 | 2.0099e-03 | -
729 | 2.6177e-04 | 3.47
2197 | 3.4703e-05 | 5.49
On the finest meshes the rate is close to p + 1. With the same 2197 unknowns, the p = 4
error is about 580 times smaller than the p = 1 error. This holds for a smooth solution; with
corners, boundary layers or curved walls the gain is smaller.
Memory
g++ -std=c++20 -O2 -I. examples/distributed/unstructured/mars_ho_memory_sweep.cpp -o mars_ho_memory_sweep
./mars_ho_memory_sweep
It counts the nonzeros of the assembled matrix exactly from the DOF map (two DOFs couple when
they share an element), at 12 bytes per nonzero (8-byte value, 4-byte column index). Its own
matrix-free column assumes one constant metric per element; the operator on this page stores the
metric at every sub-control-surface point instead. So the last column below is computed for the
operator on this page, on the same cubes: the metric (9 p (p+1)^2 doubles per element) plus the
element-to-DOF map ((p+1)^3 4-byte ids per element), divided by the DOF count.
| p | cube | DOFs | nonzeros per row | assembled matrix, B/DOF | matrix-free, B/DOF |
|---|---|---|---|---|---|
| 1 | 40^3 | 68,921 | 26 | 308.5 | 297 |
| 2 | 24^3 | 117,649 | 61 | 733.3 | 165 |
| 4 | 12^3 | 117,649 | 205 | 2,462.0 | 113 |
| 7 | 6^3 | 79,507 | 685 | 8,216.6 | 93 |
Neither column counts the vectors x and y, which both need. At p = 1 the two are about the
same size, and the 7-nonzero graph matrix of FEM Assembly (a lumped operator,
at most 84 B/DOF for values and column indices) is smaller still: at p = 1 use the assembled
kernels. From p = 2 on, the assembled matrix grows quickly with p while the matrix-free
storage per DOF falls. For a run of mars_ho_dist_apply_test, the STORAGE line prints the
metric bytes per DOF of that run.
GPU throughput and scaling
This page quotes no GPU throughput or scaling numbers. The two drivers print them for every run (MDOF/s, GDOF/s, ms per matvec, the communication share), and they depend on the GPU, the kernel variant, the order and the number of DOFs per GPU. Measure them on your system.
Other drivers
| Target | What it does |
|---|---|
mars_cvfem_ho_compare |
one GPU, p = 1: compares the matrix-free apply with the assembled 27-nonzero matrix (parity) and with the 7-nonzero graph matrix (throughput and memory). --ncells=N or --mesh=DIR, --iters=N |
mars_cvfem_ho_weakscale |
p = 1 distributed apply for weak-scaling runs. --ncells=N (global cube) or --mesh=DIR, --overlap, --irregular |
mars_ho_dist_dof_test |
distributed HO numbering and halo: global DOF count and a constant forwarded to every ghost |
mars_cvfem_ho_tet_test, mars_ho_dist_apply_tet_test |
tetrahedral HO operators (collapsed sum factorization), also experimental |
Release tests
ctest -L release (see the README)
includes two tests of this operator:
marsReleaseHoMatfreerunsmars_cvfem_ho_matfree_teston one GPU.marsReleaseHoDistApply_npNrunsmars_ho_dist_apply_test --ncells=16 --p=3on N ranks (MARS_RELEASE_TEST_RANKS, default 4) and requires both gates to pass.
Reference
R. Knaus, "A fast matrix-free approach to the high-order control volume finite element method with application to low-Mach flow", Computers & Fluids 239 (2022) 105408.