Skip to content

feat(distributed): compact diffusion leaf on partition slabs - #66

Open
kylebeggs wants to merge 1 commit into
feat/compact-diffusion-leaffrom
feat/distributed-diffusion-leaf
Open

feat(distributed): compact diffusion leaf on partition slabs#66
kylebeggs wants to merge 1 commit into
feat/compact-diffusion-leaffrom
feat/distributed-diffusion-leaf

Conversation

@kylebeggs

@kylebeggs kylebeggs commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Stage 2 of #56. Closes #57. Stacked on #60 — review that first; this PR's diff is only the distributed layer.

Diffusion joins the prepare_distributed whitelist on the same coefficient terms as ScalingOp: a real-eltype Field on an undistributed CartesianGrid, under either averaging policy.

It needs no communication

The face averaging reads κ one cell past every face, the partition cut included, so _slab_field's zero coefficient ghosts do not serve it. But _slab_op runs on the host holding the global κ, whose ghosts diffusion already extended at construction by an even mirror and a periodic wrap. Slab padded index p is global padded index first(local_range[d]) - 1 + p, so one padded window lands every ghost on the value it should hold, with no per-face logic:

slab ghost maps onto which holds
Interface (cut plane) a global interior plane the neighbour slab's κ
physical, end slabs a global ghost plane the even mirror
periodic cut a global ghost plane the wrap
transverse the whole padded extent mirror / wrap, verbatim

The window is first(zr):(last(zr) + 2h) ⊆ 1:(n + 2h), so it never needs clamping. Widening the slice is a setup-time indexing change, not a transport, and the per-apply exchange count is unchanged from the Laplacian baseline — #56's "one halo exchange per application" invariant holds trivially. That is legitimate only because κ is constant through a solve; a coefficient that changed per iteration would owe an exchange per iteration.

_slab_field's invariant is narrowed, not broken. It still holds verbatim for ScalingOp, which reads its coefficient pointwise; Diffusion opts into the widened _slab_coeff_field instead.

Deviation from the issue. #57 sketches _slab_coeff_field as an interior slice, then fill_coefficient_ghosts!, then an explicit per-face copy of neighbour interior planes with its own periodic-wrap branch. The padded window above produces the same values in six lines with no branches, so that is what landed.

What did not change

  • The public diffusion(g, κ) still refuses Interface faces. _slab_op builds through the inner constructor, which is the seam its docstring reserved. Its error message now points at prepare_distributed instead of claiming slabs are unsupported.
  • The distributability guards still run once on the global tree, before localization.
  • operator_diagonal(::Diffusion) keeps its Interface rejection — it serves multigrid, which is not distributed.

This PR is also the first production path to reach the leaf's mechanical transpose: a slab carries Interface faces, so apply_adjoint! takes the adjoint_gather! branch that scatters cotangents into ghosts for the slab reduction to fold, rather than the self-adjoint shortcut an all-physical grid takes. That code landed in #48 with a hand-built test; here it runs for real.

Coverage

test/partitioning.jl gains 2514 assertions over 1-D/2-D/3-D grids (the cut always falls on dimension N, so grid rank is how "a cut in each dimension" is reached), both averaging policies, Dirichlet/Neumann and periodic cuts, and 2 and 3 partitions: padded-slice exactness cell by cell against an independently spelled oracle, bitwise forward parity, the dot-product adjoint identity, dense forward/transpose structure via dist_materialize, boundary_rhs parity, and an assertion that the exchange gating matches the Laplacian's node for node.

Every positive claim is paired with a control. The load-bearing one zeroes only the cut-plane ghosts of each slab's localized κ — exactly what _slab_field would have left — and demands the answer move. Verified by sabotage: swapping _slab_coeff_field for _slab_field in _slab_op turns 3372 passes into 3040 passes / 332 failures, concentrated in the dense-parity, ghost-poison, and boundary_rhs testsets. The κ used throughout varies across the cut and is asymmetric about it; a constant κ would hide a zeroed ghost entirely.

test/mdla_gpu.jl gains the extension-path twins — accepted list, a padded-κ upload check, forward parity, adjoint identity and dense structure, boundary_rhs parity, and -1.0 * diffusion(g, κ) as an SPD system in the CG end-to-end. Those were not run locally: test/mdla.jl is gated on MFO_TEST_MDLA=true with ≥2 CUDA devices, and this machine has neither. Their tree-shape and padded-window assertions were validated against the CPU harness, but the GPU matrix is the real check.

Local: full Pkg.test() green — 8163 pass, 3 pre-existing broken, 0 fail.


🤖 Written by Claude, who slid one index window sideways and called it a distributed algorithm.

@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-66/

Built to branch gh-pages at 2026-08-14 16:35 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

3edc7d1... 437a878... 3edc7d1... / 437a878...
forest/2D 64×32²/halo_update! 20.9 ± 0.22 μs 22.1 ± 0.24 μs 0.947 ± 0.014
forest/2D 64×32²/laplacian mul! 0.115 ± 0.0022 ms 0.117 ± 0.002 ms 0.987 ± 0.025
forest/2D 64×32²/laplacian mul! (packed) 0.105 ± 0.002 ms 0.108 ± 0.0017 ms 0.974 ± 0.024
forest/2D 64×32²/prepare 0.0871 ± 0.0086 ms 0.0872 ± 0.014 ms 0.999 ± 0.19
forest/2D refined/halo_update! 0.197 ± 0.0078 ms 0.201 ± 0.0073 ms 0.98 ± 0.053
forest/2D refined/halo_update_adjoint! 0.23 ± 0.0097 ms 0.235 ± 0.01 ms 0.977 ± 0.059
forest/2D refined/laplacian apply_adjoint! 1.48 ± 0.015 ms 1.49 ± 0.016 ms 0.992 ± 0.014
grid/2D 256²/(∂x + ∂y)ᵀ adjoint 0.786 ± 0.0024 ms 0.785 ± 0.0016 ms 1 ± 0.0037
grid/2D 256²/2λ + κ·I mul! 0.113 ± 0.0022 ms 0.112 ± 0.0016 ms 1.01 ± 0.025
grid/2D 256²/adjoint(∂x + ∂y) mul! 0.828 ± 0.0074 ms 0.83 ± 0.0059 ms 0.997 ± 0.011
grid/2D 256²/advection mul! 0.0712 ± 0.003 ms 0.0719 ± 0.001 ms 0.991 ± 0.044
grid/2D 256²/diffusion mul! 0.0933 ± 0.0019 ms 0.094 ± 0.0016 ms 0.993 ± 0.027
grid/2D 256²/diffusion prepare 5.29 ± 2.2 μs 6.58 ± 3.2 μs 0.804 ± 0.51
grid/2D 256²/divergence mul! 0.107 ± 0.0017 ms 0.109 ± 0.0012 ms 0.981 ± 0.019
grid/2D 256²/divergenceᵀ adjoint (β ≠ 0) 0.206 ± 0.0056 ms 0.206 ± 0.005 ms 1 ± 0.037
grid/2D 256²/gradient mul! 0.175 ± 0.0089 ms 0.177 ± 0.01 ms 0.987 ± 0.076
grid/2D 256²/gradientᵀ adjoint (β ≠ 0) 0.362 ± 0.01 ms 0.362 ± 0.01 ms 1 ± 0.04
grid/2D 256²/laplacian mul! 0.0637 ± 0.0011 ms 0.0635 ± 0.0008 ms 1 ± 0.022
grid/2D 256²/laplacian prepare 5.19 ± 2.4 μs 6.58 ± 4 μs 0.788 ± 0.6
grid/2D 256²/∂x adjoint (β = 0) 0.377 ± 0.01 ms 0.377 ± 0.01 ms 1 ± 0.038
grid/2D 256²/∂x adjoint (β ≠ 0) 0.399 ± 0.01 ms 0.419 ± 0.01 ms 0.951 ± 0.034
grid/2D 256²/∇·(κ∇u) mul! 0.255 ± 0.0098 ms 0.261 ± 0.01 ms 0.977 ± 0.053
grid/2D 256²/∇·(κ∇u) prepare 14.8 ± 9.4 μs 19.7 ± 9.3 μs 0.755 ± 0.6
grid/3D 64³/diffusion mul! 0.547 ± 0.013 ms 0.546 ± 0.015 ms 1 ± 0.036
grid/3D 64³/laplacian mul! 0.349 ± 0.012 ms 0.345 ± 0.013 ms 1.01 ± 0.052
grid/3D 64³/laplacian prepare 0.0925 ± 0.019 ms 0.113 ± 0.022 ms 0.821 ± 0.23
time_to_load 0.298 ± 0.0028 s 0.304 ± 0.0019 s 0.981 ± 0.011

Memory and allocations

3edc7d1... 437a878... 3edc7d1... / 437a878...
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²/diffusion mul! 2 allocs: 0.0938 kB 2 allocs: 0.0938 kB 1
grid/2D 256²/diffusion prepare 6 allocs: 1.02 MB 6 allocs: 1.02 MB 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³/diffusion mul! 2 allocs: 0.0938 kB 2 allocs: 0.0938 kB 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
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).

Stage 2 of #56, closes #57. Diffusion joins the prepare_distributed
whitelist on the same coefficient terms as ScalingOp.

The face averaging reads κ one cell past every face, the partition cut
included, so _slab_field's zero coefficient ghosts do not serve it. It
still needs no communication: _slab_op runs on the host holding the
global κ, whose ghosts diffusion already extended by an even mirror and
a periodic wrap, and slab padded index p is global padded index
first(local_range[d]) - 1 + p. One padded window — _slab_coeff_field —
therefore lands every ghost on the value it should hold with no per-face
logic: an Interface ghost onto a global interior plane (the neighbour's
κ), a wall ghost onto the global mirror, a periodic cut onto the global
wrap. Widening the slice is a setup-time indexing change, not a
transport, so the per-apply exchange count is unchanged from the
Laplacian baseline.

_slab_field's invariant is narrowed, not broken — it still holds verbatim
for ScalingOp, which reads its coefficient pointwise. The public
diffusion(g, κ) keeps rejecting Interface faces; _slab_op builds through
the inner constructor, which is the seam that docstring reserved.

This is also the first production path to reach the leaf's mechanical
transpose: a slab carries Interface faces, so apply_adjoint! takes the
adjoint_gather! branch rather than the self-adjoint shortcut.
@kylebeggs
kylebeggs force-pushed the feat/distributed-diffusion-leaf branch from 5f37161 to 437a878 Compare August 14, 2026 16:31
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.

feat(distributed): compact diffusion leaf on partition slabs

1 participant