Skip to content

feat(operators): compact flux-form diffusion leaf - #60

Open
kylebeggs wants to merge 5 commits into
mainfrom
feat/compact-diffusion-leaf
Open

feat(operators): compact flux-form diffusion leaf#60
kylebeggs wants to merge 5 commits into
mainfrom
feat/compact-diffusion-leaf

Conversation

@kylebeggs

@kylebeggs kylebeggs commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Stage 1 of #56: the compact flux-form ∇·(κ∇u) leaf on a uniform CartesianGrid.

Closes #48.

What and why

divergence(g) * scaling(κ) * gradient(g) chains two collocated centered first differences, so its row at cell I samples the flux κ∇u only at I±e. No equation ever couples κ at two adjacent cells, and a κ-inversion decouples exactly into the even and odd (i+j)-parity sublattices — fit to disjoint halves of the noisy data, tied together only by the regularizer.

diffusion(g, κ) is the finite-volume form instead: face fluxes κ_{i+½}(u_{i+1} − u_i)/Δ differenced across the cell, with κ averaged to faces by ArithmeticMean (default) or HarmonicMean.

The measured effect, in examples/inverse_diffusion.jl (n=48, 1% noise, same optimizer):

rel. error jaggedness (truth 0.056)
1 excitation, λ = 0 3.09 % 0.069
1 excitation, λ = 5e-4 3.09 % 0.069
3 excitations, λ = 5e-4 1.42 % 0.061

The prior is now inert — five orders of magnitude of λ move the answer by under 0.01 percentage points. Under the wide stencil, λ was the difference between a usable answer and speckle, and a 20× stronger prior still could not rescue it. The example accordingly drops from three drive patterns to one; the extra drives existed only to shrink each sublattice's variance until the interleaved halves agreed.

Beyond accuracy

  • Exactly symmetric for real κ — asymmetry measures as a hard zero, not a tolerance, across Dirichlet/Neumann/periodic and mixed combinations. The composed form is not symmetric; test/algebra.jl can only check its declared transpose against the dense transpose.
  • Declares operator_diagonal, which the composition structurally cannot (operator_diagonal(::Composed) needs both factors diagonal). This is the only spelling of variable-coefficient diffusion a Jacobi or Chebyshev multigrid smoother can touch, and _rediscretize coarsens κ with the child mean rather than Restriction.
  • Faster: 115 µs against the composition's 337 µs for a 256² prepared mul! — 2.9×. Against the Laplacian's 67.5 µs it costs 1.7× for 2× the memory traffic, so the second array rides largely free. (From this PR's benchmark comment, head 1c365e4: diffusion mul! is the leaf, ∇·(κ∇u) mul! the composition, laplacian mul! the baseline — three entries from the same run, so the comparison carries no cross-revision runner noise.)
  • Now the recommended spelling. The README and the scaling / divergence docstrings advertised divergence(g) * scaling(κ) * gradient(g) as the way to write ∇·(κ∇u) — scaling's worked example handed it over labelled # ∇·(κ∇u) with no caveat. All of them now lead with diffusion(g, κ); the composition stays, framed as the algebra stress-test §8 says it is, with the wide-stencil cost stated.

Boundary treatment is exact, not approximate

The package's homogeneous fills reflect about the face. A Neumann face therefore carries zero flux, so its coefficient is irrelevant; a Dirichlet face carries κ_I(0 − u_I)/(Δ/2), which the one-sided face coefficient κ_f = κ_I reproduces exactly. Worth knowing when inverting: that one-sided value is O(Δ) accurate in κ at a wall while interior faces are O(Δ²), so wall-adjacent κ cells have a different sensitivity structure. Documented in the docstring.

The leaf stores its own copy of κ with ghosts extended by fill_coefficient_ghosts! — an even mirror at walls, a periodic wrap — deliberately not apply_bc!, whose Dirichlet antisymmetric mirror would negate a material coefficient at a wall. Same hazard _average_to_coarse documents for coarsening.

Two things worth a reviewer's attention

The diagonal has a case a _bc_sign-style correction misses. A face's diagonal weight is s_f − 1 for s_f = ∂u[neighbour slot]/∂u_I: −1 interior, −2 Dirichlet, 0 Neumann, and 0 for a periodic dimension of a single cell, where the wrap neighbour is the cell itself. Laplacian has the same latent bug today — filed separately as #55, not fixed here. The operator_diagonal test sweeps n ∈ {(5,4), (3,3), (2,2), (1,4), (4,1)} against diag(materialize(...)) precisely to pin this.

Dimensions are unrolled by recursion, not ntuple(Val(N)) do d. With a second array in the stencil that closure stops inlining and the broadcast loses vectorization — 260 µs vs 33 µs for one 256² sweep, with identical numerics. The first version of this leaf was 10× slower than the composition it replaces because of it. Added to CLAUDE.md's gotchas; please keep _diff_axes closure-free.

Scope

CartesianGrid only. An Interface face has no cross-block κ to average, so the constructor rejects forests and slabs and _distributable stays false — staged as #57 / #58 / #59. The declared transpose gather for Interface grids is nevertheless implemented and tested here (through the inner constructor, with κ ghosts filled by hand), since that is what #58 will build on.

AD stays rule-free on the κ path, asserted rather than assumed: test/enzyme_rules.jl checks rule_hits() is unchanged across a Diffusion gradient, and κ ghost entries get zero gradient in both Enzyme and the finite-difference reference.

The advertised `divergence(g) * scaling(κ) * gradient(g)` chains two centered
first differences, giving a wide 2h stencil whose row at cell I samples the flux
κ∇u only at I±e. No equation ever couples κ at adjacent cells, so a κ-inversion
decouples exactly into even/odd (i+j)-parity sublattices fit to disjoint halves of
the data. `Diffusion` is the finite-volume form instead: face fluxes
κ_{i+½}(u_{i+1}-u_i)/Δ differenced across the cell, with κ averaged to faces by
`ArithmeticMean` (default) or `HarmonicMean`.

Beyond removing the decoupling, the compact form is exactly symmetric for real κ
(the composed form is not) and declares `operator_diagonal`, which the composition
cannot — `operator_diagonal(::Composed)` needs both factors diagonal. That unlocks
Jacobi/Chebyshev multigrid for variable-coefficient diffusion, via `_rediscretize`
using the child mean rather than `Restriction`.

Boundary treatment is exact rather than approximate. The package's homogeneous
fills reflect about the face, so a Neumann face carries zero flux (its coefficient
is irrelevant) and a Dirichlet face carries κ_I(0-u_I)/(Δ/2), which the one-sided
face coefficient κ_f = κ_I reproduces exactly. The leaf stores its own copy of κ
with ghosts extended by `fill_coefficient_ghosts!` — an even mirror at walls and a
periodic wrap, deliberately not `apply_bc!`, which would antisymmetrize a material
coefficient at a Dirichlet wall.

Two implementation notes worth keeping. The diagonal weight of a face is s_f - 1
for s_f = ∂u[neighbour]/∂u_I, which covers the case a `_bc_sign`-style correction
misses: a periodic dimension of a single cell wraps onto itself. And dimensions are
unrolled by recursion, not `ntuple(Val(N)) do d` — with a second array in the
stencil that closure stops inlining and the broadcast loses vectorization, costing
8x with identical numerics (260 vs 33 µs per 256² sweep).

Scoped to CartesianGrid: an Interface face has no cross-block κ to average, so the
constructor rejects forests and slabs and `_distributable` stays false. Staged in
#54. Measured 62 µs vs the composition's 115 µs for a 256² prepared `mul!`.

Closes #48.
The example used three drive patterns because one was not enough: under the wide
composed stencil a single-drive reconstruction speckled, and the extra drives
shrank each parity sublattice's variance until the interleaved halves agreed. That
was a workaround for the discretization, not a property of the inverse problem.

With `diffusion(g, κ)` one excitation suffices — 3.09% relative error at n=48 with
1% noise, and a jaggedness of 0.069 against the truth's 0.056, so no checkerboard.
The residual error is faint streaking where ∇u is small, which is a genuine
resolution limit of one drive rather than a stencil artifact.

The smoothness prior is now inert: sweeping λ from 0 to 5e-4 moves the recovery by
under 0.01 percentage points, where before it was the difference between a usable
answer and speckle. λ is kept only so the knob stays visible.

Also reports relative error and jaggedness instead of asserting the improvement in
prose, and records in DESIGN.md §10.2 that a staggered G/D pair cannot assume
D = -Gᵀ: the compact operator factors as -Bᵀ diag(κ_f) B, but at a Dirichlet wall
that row of B is √2/Δ, not 2/Δ.
@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://RallypointOne.github.io/MatrixFreeOperators.jl/pr-preview/pr-60/

Built to branch gh-pages at 2026-08-14 16:31 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown

Benchmark Results

Time

ba2626e... 3edc7d1... ba2626e... / 3edc7d1...
forest/2D 64×32²/halo_update! 21.1 ± 0.22 μs 23.6 ± 0.2 μs 0.891 ± 0.012
forest/2D 64×32²/laplacian mul! 0.111 ± 0.0017 ms 0.112 ± 0.0017 ms 0.984 ± 0.021
forest/2D 64×32²/laplacian mul! (packed) 0.104 ± 0.0019 ms 0.105 ± 0.0017 ms 0.983 ± 0.024
forest/2D 64×32²/prepare 0.0879 ± 0.021 ms 0.0887 ± 0.019 ms 0.991 ± 0.32
forest/2D refined/halo_update! 0.193 ± 0.0063 ms 0.199 ± 0.0058 ms 0.966 ± 0.042
forest/2D refined/halo_update_adjoint! 0.229 ± 0.01 ms 0.234 ± 0.0094 ms 0.98 ± 0.058
forest/2D refined/laplacian apply_adjoint! 1.47 ± 0.015 ms 1.47 ± 0.013 ms 0.999 ± 0.013
grid/2D 256²/(∂x + ∂y)ᵀ adjoint 0.785 ± 0.0016 ms 0.744 ± 0.0024 ms 1.05 ± 0.004
grid/2D 256²/2λ + κ·I mul! 0.105 ± 0.0022 ms 0.105 ± 0.0017 ms 1 ± 0.027
grid/2D 256²/adjoint(∂x + ∂y) mul! 0.788 ± 0.0074 ms 0.79 ± 0.0079 ms 0.997 ± 0.014
grid/2D 256²/advection mul! 0.0617 ± 0.00092 ms 0.0614 ± 0.0013 ms 1 ± 0.026
grid/2D 256²/divergence mul! 0.105 ± 0.0018 ms 0.105 ± 0.0017 ms 1 ± 0.024
grid/2D 256²/divergenceᵀ adjoint (β ≠ 0) 0.204 ± 0.0033 ms 0.205 ± 0.0033 ms 0.991 ± 0.023
grid/2D 256²/gradient mul! 0.175 ± 0.0049 ms 0.171 ± 0.0087 ms 1.03 ± 0.059
grid/2D 256²/gradientᵀ adjoint (β ≠ 0) 0.374 ± 0.011 ms 0.362 ± 0.011 ms 1.03 ± 0.043
grid/2D 256²/laplacian mul! 0.0552 ± 0.0009 ms 0.0539 ± 0.00064 ms 1.02 ± 0.021
grid/2D 256²/laplacian prepare 5.64 ± 4.7 μs 6.14 ± 4.4 μs 0.918 ± 1
grid/2D 256²/∂x adjoint (β = 0) 0.377 ± 0.011 ms 0.356 ± 0.01 ms 1.06 ± 0.043
grid/2D 256²/∂x adjoint (β ≠ 0) 0.4 ± 0.01 ms 0.399 ± 0.011 ms 1 ± 0.037
grid/2D 256²/∇·(κ∇u) mul! 0.257 ± 0.01 ms 0.248 ± 0.0096 ms 1.04 ± 0.058
grid/2D 256²/∇·(κ∇u) prepare 15.1 ± 11 μs 16.2 ± 9 μs 0.932 ± 0.84
grid/3D 64³/laplacian mul! 0.316 ± 0.012 ms 0.318 ± 0.011 ms 0.994 ± 0.051
grid/3D 64³/laplacian prepare 0.0992 ± 0.042 ms 0.116 ± 0.018 ms 0.853 ± 0.38
grid/2D 256²/diffusion prepare 6.16 ± 3.2 μs
grid/3D 64³/diffusion mul! 0.511 ± 0.012 ms
grid/2D 256²/diffusion mul! 0.0867 ± 0.0011 ms
time_to_load 0.293 ± 0.0027 s 0.298 ± 0.00067 s 0.983 ± 0.0095

Memory and allocations

ba2626e... 3edc7d1... ba2626e... / 3edc7d1...
forest/2D 64×32²/halo_update! 0 allocs: 0 B 0 allocs: 0 B
forest/2D 64×32²/laplacian mul! 0 allocs: 0 B 0 allocs: 0 B
forest/2D 64×32²/laplacian mul! (packed) 0 allocs: 0 B 0 allocs: 0 B
forest/2D 64×32²/prepare 0.582 k allocs: 1.72 MB 0.582 k allocs: 1.72 MB 1
forest/2D refined/halo_update! 0 allocs: 0 B 0 allocs: 0 B
forest/2D refined/halo_update_adjoint! 0 allocs: 0 B 0 allocs: 0 B
forest/2D refined/laplacian apply_adjoint! 0 allocs: 0 B 0 allocs: 0 B
grid/2D 256²/(∂x + ∂y)ᵀ adjoint 0 allocs: 0 B 0 allocs: 0 B
grid/2D 256²/2λ + κ·I mul! 2 allocs: 0.0938 kB 2 allocs: 0.0938 kB 1
grid/2D 256²/adjoint(∂x + ∂y) mul! 2 allocs: 0.0938 kB 2 allocs: 0.0938 kB 1
grid/2D 256²/advection mul! 2 allocs: 0.0938 kB 2 allocs: 0.0938 kB 1
grid/2D 256²/divergence mul! 1 allocs: 0.0469 kB 1 allocs: 0.0469 kB 1
grid/2D 256²/divergenceᵀ adjoint (β ≠ 0) 0 allocs: 0 B 0 allocs: 0 B
grid/2D 256²/gradient mul! 1 allocs: 0.0469 kB 1 allocs: 0.0469 kB 1
grid/2D 256²/gradientᵀ adjoint (β ≠ 0) 0 allocs: 0 B 0 allocs: 0 B
grid/2D 256²/laplacian mul! 2 allocs: 0.0938 kB 2 allocs: 0.0938 kB 1
grid/2D 256²/laplacian prepare 6 allocs: 1.02 MB 6 allocs: 1.02 MB 1
grid/2D 256²/∂x adjoint (β = 0) 0 allocs: 0 B 0 allocs: 0 B
grid/2D 256²/∂x adjoint (β ≠ 0) 0 allocs: 0 B 0 allocs: 0 B
grid/2D 256²/∇·(κ∇u) mul! 2 allocs: 0.0938 kB 2 allocs: 0.0938 kB 1
grid/2D 256²/∇·(κ∇u) prepare 18 allocs: 5.08 MB 18 allocs: 5.08 MB 1
grid/3D 64³/laplacian mul! 2 allocs: 0.0938 kB 2 allocs: 0.0938 kB 1
grid/3D 64³/laplacian prepare 6 allocs: 4.39 MB 6 allocs: 4.39 MB 1
grid/2D 256²/diffusion prepare 6 allocs: 1.02 MB
grid/3D 64³/diffusion mul! 2 allocs: 0.0938 kB
grid/2D 256²/diffusion mul! 2 allocs: 0.0938 kB
time_to_load 0.145 k allocs: 11 kB 0.145 k allocs: 11 kB 1

Benchmark Plots

A plot of the benchmark results have been uploaded as an artifact to the workflow run for this PR.
Go to "Actions"->"Benchmark a pull request"->[the most recent run]->"Artifacts" (at the bottom).

Building the leaf inside the differentiated region stores the Const grid into
the freshly built active coefficient Field. Enzyme's static activity analysis
clears that on Julia 1.12 but not on 1.10 or 1.11, so all three κ-gradient
tests through `diffusion` errored with EnzymeRuntimeActivityError there.

Use set_runtime_activity at those call sites — the workaround already used at
every other Enzyme call in the suite — and say so in the `diffusion` docstring
and docs/pages/autodiff.qmd, since anyone inverting for κ hits the same wall.
The quoted pair labelled the leaf's own 115 µs as the composition's. Benchmark
CI has the leaf at 115 µs, the composition at 337 µs, the Laplacian at 67.5 µs
for the same 256² prepared mul!.
…tion

The README and the scaling/divergence docstrings advertised
divergence(g) * scaling(κ) * gradient(g) as the way to spell variable-coefficient
diffusion — scaling's worked example handed it over labelled ∇·(κ∇u) with no
caveat. Lead with diffusion(g, κ) everywhere; keep the composition as what it is,
a demonstration that the algebra composes, with the wide-stencil cost stated.
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.

Compact flux-form diffusion: fix the collocated checkerboard decoupling

1 participant