Skip to content

perf(amr): fuse each coarse–fine ghost fill into one gather pass - #96

Merged
kylebeggs merged 3 commits into
mainfrom
perf/fused-gather-fills
Sep 11, 2026
Merged

kylebeggs merged 3 commits into
mainfrom
perf/fused-gather-fills

Conversation

@kylebeggs

@kylebeggs kylebeggs commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Closes #84.

Stacked on #42 (perf/ghostfill-isbits) — merge that first; this branch is built on its CSR term buffers and does not revert any of it.

Problem

_run_fills! wrote each coarse–fine ghost slab once per term: dst .= w₁·src₁ then one dst .+= wₖ·srcₖ per remaining term. An interpolation row is 1 + 2·3^(N−1) terms — 7 in 2D, 19 in 3D — and there are 16 fills per coarse–fine face in 3D, so one 3D interface face cost roughly 300 separate broadcasts over slabs a few cells wide, each re-reading and re-writing the same handful of dst cells. Broadcast setup, not arithmetic, was the cost: on a refined 3D forest the fill phases were about 90% of the whole forward exchange.

Fix

One fused gather per fill: dst[I] = Σₖ wₖ · srcₖ[I], evaluated in a single broadcast over the dst slab's CartesianIndices. The phase's row width is a function of the dimension alone, so _run_fills! takes it as a Val(K) (_interp_width / _restrict_width fold Val(N) to Val(7)/Val(19)/Val(5)/Val(9)), and the K term views and weights are built by recursion down the CSR row rather than by ntuple(Val(K)) do k over two arrays — the same non-inlining trap _diff_axes documents in operators/diffusion.jl.

Three things make that legal, and all three are now enforced rather than assumed:

  • Term and accumulation order are unchanged — seed with term 1, add term k to the running sum, left-associated, no fma. So the result is bit-identical to the retired loop whenever the field eltype is the schedule's weight type.
  • Every term window has the dst slab's shape, and every term window is disjoint from the dst slab. Both are now stated in the GhostFill docstring and asserted per term in _close_fill at schedule build. Disjointness became load-bearing with the fusion: the term views ride an immutable Ref subtype (_AsScalar) so that the un-inlined gather body does not heap-allocate them, and Base's broadcast_unalias never sees them as a result.
  • A per-fill row-width and bounds check licenses the @inbounds walk down terms, so a malformed row throws instead of reading out of bounds.

One intentional behavioural change. In mixed precision — a Float32 field on a Float64 forest — the fused gather is not bit-identical to the retired loop: the loop rounded its partial sum into the field's eltype K−1 times, the fused body keeps the promoted accumulator and rounds once. That is the device CSR kernel's arithmetic, so the fusion removes a pre-existing host/device divergence rather than introducing one, and the claim in the code and in DESIGN.md is narrowed to say exactly that. Measured on cascaded 3-level refined forests: 155 of 6400 cells differ in 2D and 3645 of 158 976 in 3D, all within 4·eps(Float32) absolute (max |Δ| = 1.8e-7 in 2D, 4.8e-7 in 3D).

Adjoint: deliberately out of scope, filed as #94

halo_update_adjoint! keeps its per-term scatter-add and is untouched here, which makes it the slower half of the exchange — 2.6× the fused forward in 2D and 4.4–4.5× on the refined 3D forests. That is a known, measured, accepted state, not an oversight.

The reason it was not fused: a fill's term windows collide on shared source cells, so a dst-centric single pass would accumulate into a colliding cell in dst-cell order instead of term order and change the roundoff. Measured collisions: 40 960 of 77 824 scatter-adds collide for 3D 32³/8³ interpolation, and 0 for every restriction phase and for 3D 32³/4³ interpolation. Fusing only the collision-free rows was considered and rejected — it would make the exchange's cost depend on the blocksize in a way nothing in the API hints at. The bit-identical route is a source-centric transposed CSR built at schedule time: cell-granular, ~78 k contributions for one 3D interpolation phase, rebuilt on every regrid, and unable to precompute linear indices because BlockField and PackedBlockField address the same cell differently. That is the descriptor bloat #42 just removed, so it needs its own design pass. Issue #94 carries the collision data, the lopsidedness numbers, and the acceptance criteria.

Tests

test/exchange_schedule.jl gains a ProbeArray — block storage that tallies every element write and every broadcast materialized into a view of it — and asserts that one phase run issues exactly one broadcast per fill and writes every dst cell exactly once. This is a structural guard, not a numeric one: I verified it bites by @evaling _run_fills! back to the per-term loop in the loaded module and re-running the file. It fails with 26 assertions — the per-fill broadcast count, the per-cell write count, the row-width throw, and the device-kernel parity below — where the base branch's suite passes unchanged under the same reversion.

Also added: the mixed-precision case, comparing the fused host fills against _run_fills_device! on the CPU backend for a Float32 field on a Float64 forest. The fused result matches the device kernel exactly; the retired loop does not, and the test asserts both directions. And _close_fill invariant tests — hand-built rows that are the wrong width, the wrong shape, or that read their own dst all throw, and both invariants are re-checked on every row the real emitters produce in 2D and 3D.

test/forest_parity.jl, test/exchange_kernels.jl, test/forest_amr.jl, test/forest_packed.jl and test/blockforest.jl all pass unchanged. Independently of the suite, I byte-compared the exchanged padded storage (ghosts included) against main for both layouts, forward and adjoint, on refined 2D 256²/32², refined 3D 32³/8³, refined 3D 32³/4³ and cascaded 3-level 2D/3D forests: identical everywhere, with the sole exception of the documented mixed-precision forward case above.

Performance

Method: Apple M5 Pro, macOS (Darwin 25.6.0), Julia 1.13.0 aarch64, -t1, machine otherwise idle (no other Julia processes at any point). Three project environments, one per revision, each Pkg.developing a separate worktree. One fresh process per (revision, round); rounds run main → #42 → this branch, five times. Within a process: 3 warmup calls, then the minimum over 60 reps of five calls, GC.gc(false) between reps. The table is the minimum of the five per-round minima. Round-to-round spread (max/min of the per-round minima) is 2.5% median, 7.2% at p90, 9.2% worst — the forward margins below are 2.7× to 7.2×, far outside it. Forests are refine!(bf, p -> p[1] < 0.5), maxlevel 2. The #42 column is this branch's base, so the middle ratio is what this PR contributes.

forest layout sweep main #42 this branch vs #42 vs main
2D 256²/32² BlockField halo_update! 34.5 µs 34.1 µs 21.6 µs 0.63× 0.63×
2D 256²/32² BlockField _run_fills! 19.9 µs 19.9 µs 7.4 µs 0.37× 0.37×
2D 256²/32² BlockField halo_update_adjoint! 41.4 µs 41.1 µs 40.8 µs 0.99× 0.98×
2D 256²/32² BlockField _run_fills_adjoint! 22.6 µs 22.0 µs 22.1 µs 1.00× 0.98×
2D 256²/32² packed halo_update! 41.0 µs 41.0 µs 18.7 µs 0.46× 0.46×
2D 256²/32² packed _run_fills! 28.6 µs 28.6 µs 5.2 µs 0.18× 0.18×
2D 256²/32² packed halo_update_adjoint! 50.4 µs 49.9 µs 49.2 µs 0.99× 0.98×
2D 256²/32² packed _run_fills_adjoint! 31.4 µs 30.4 µs 30.4 µs 1.00× 0.97×
3D 32³/8³ BlockField halo_update! 633.8 µs 631.7 µs 228.2 µs 0.36× 0.36×
3D 32³/8³ BlockField _run_fills! 561.5 µs 557.1 µs 161.2 µs 0.29× 0.29×
3D 32³/8³ BlockField halo_update_adjoint! 788.0 µs 769.5 µs 779.3 µs 1.01× 0.99×
3D 32³/8³ BlockField _run_fills_adjoint! 619.6 µs 603.7 µs 613.1 µs 1.02× 0.99×
3D 32³/8³ packed halo_update! 773.7 µs 779.4 µs 203.7 µs 0.26× 0.26×
3D 32³/8³ packed _run_fills! 693.6 µs 705.0 µs 134.6 µs 0.19× 0.19×
3D 32³/8³ packed halo_update_adjoint! 924.2 µs 913.7 µs 907.8 µs 0.99× 0.98×
3D 32³/8³ packed _run_fills_adjoint! 743.3 µs 730.1 µs 728.1 µs 1.00× 0.98×
3D 32³/4³ BlockField halo_update! 2461 µs 2430 µs 788 µs 0.32× 0.32×
3D 32³/4³ BlockField _run_fills! 2084 µs 2042 µs 405 µs 0.20× 0.19×
3D 32³/4³ BlockField halo_update_adjoint! 3107 µs 2862 µs 2863 µs 1.00× 0.92×
3D 32³/4³ BlockField _run_fills_adjoint! 2399 µs 2187 µs 2203 µs 1.01× 0.92×
3D 32³/4³ packed halo_update! 3003 µs 2969 µs 754 µs 0.25× 0.25×
3D 32³/4³ packed _run_fills! 2604 µs 2573 µs 360 µs 0.14× 0.14×
3D 32³/4³ packed halo_update_adjoint! 3656 µs 3330 µs 3338 µs 1.00× 0.91×
3D 32³/4³ packed _run_fills_adjoint! 2864 µs 2645 µs 2641 µs 1.00× 0.92×

So the forward fill sweep is 2.7× to 7.2× faster and the whole forward exchange 1.6× to 4.0× faster, with the largest wins on packed 3D — the layout and dimension the AMR work is aimed at. The adjoint legs move by at most 2%, which is inside the spread, and nothing regresses against main on any path.

Allocations stay at 0 bytes per call for both sweeps, both directions, both layouts, on all three forests — asserted in the suite with @inferred on both directions, and confirmed here out of band (@allocated over a 200-call loop, divided). The _AsScalar immutable Ref is what buys this: a mutable RefValue escapes the un-inlined gather body and heap-allocates the whole tuple of views on every fill, inside the _exchange_storage! rule seam that must not allocate.

Schedule build cost is unchanged despite the new per-term disjointness assertion: 0.56 / 17.8 / 73.8 ms on the three forests, against 0.58 / 17.9 / 75.5 on #42 and 0.59 / 18.6 / 77.2 on main.

Compile latency

This is the real cost, and it is not small. First halo_update! on a refined 3D 32³/8³ forest in a fresh process, two runs each:

revision BlockField packed
main 652 / 663 ms 671 / 678 ms
#42 658 / 658 ms 674 / 712 ms
this branch 1260 / 1213 ms 1217 / 1207 ms

Roughly a doubling, about +550 ms one-time per specialization, inherent to specializing the gather body over Val(K). Against a forward saving of ~570 µs per call on that forest, it pays for itself after about a thousand exchanges — fine for a Krylov or multigrid solve, a net loss for a script that exchanges a few dozen times. The first halo_update_adjoint! after it also rises, 205 → 306 ms, since it now follows a heavier forward compile.

Not measured

  • GPU. No CUDA device here and MFO_TEST_GPU unset. The one path that reaches this host broadcast on a GPU is a refined BlockField of device arrays; it ships K device SubArrays as kernel parameters, about 2.4 kB for a 19-term 3D interpolation fill against CUDA's 4 kB parameter budget on pre-sm_90 hardware. It fits with under 2× headroom and none for a wider stencil or a 4th dimension, and that is documented beside _AsScalar. Packed fields are unaffected — they take the batched CSR kernels. The _AsScalar Adapt rule has only been exercised against a hand-rolled adaptor.
  • Enzyme on Linux x86_64. The compile-latency increase above is the concern most likely to show up somewhere else, so I timed the AD suites on Julia 1.12.7 x86_64 (macOS/Rosetta, the closest local proxy for the CI platform): test/enzyme_rules.jl 46/46 in 3m47 against 3m36 on perf(amr): make GhostFill isbits with an NTuple of terms #42, and test/autodiff.jl 54/54 in 4m49 against 4m44. So the latency does not propagate there. But that is Rosetta, not native Linux, and per CLAUDE.md a local green run is not evidence about CI — read the matrix.
  • benchpkg CI. All numbers are local, single-threaded, one machine. benchpkg on shared runners routinely shows ±15–20% on untouched entries.
  • The ProbeArray broadcast count runs the BlocksLayout only. PackedBlockField goes through the same _run_fills! method so the count applies to it, but it is not separately counted.
    https://claude.ai/code/session_016F4h1y22x3ohpRdGVCpjHP

@kylebeggs
kylebeggs force-pushed the perf/fused-gather-fills branch from b9e9250 to 5155be4 Compare September 10, 2026 12:33
@kylebeggs kylebeggs changed the title perf/fused gather fills perf(amr): fuse each coarse–fine ghost fill into one gather pass Sep 10, 2026
@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown

Benchmark Results

Time

bacc949... ab2664f... bacc949... / ab2664f...
forest/2D 64×32²/halo_update! 20 ± 0.071 μs 20.4 ± 0.08 μs 0.982 ± 0.0052
forest/2D 64×32²/laplacian mul! 0.111 ± 0.00074 ms 0.111 ± 0.00068 ms 1 ± 0.0091
forest/2D 64×32²/laplacian mul! (packed) 0.0994 ± 0.00059 ms 0.1 ± 0.00061 ms 0.992 ± 0.0085
forest/2D 64×32²/prepare 0.0929 ± 0.033 ms 0.0979 ± 0.045 ms 0.949 ± 0.55
forest/2D refined/halo_update! 0.19 ± 0.0044 ms 0.0889 ± 0.00088 ms 2.13 ± 0.054
forest/2D refined/halo_update_adjoint! 0.22 ± 0.0097 ms 0.216 ± 0.0041 ms 1.02 ± 0.049
forest/2D refined/laplacian apply_adjoint! 1.51 ± 0.012 ms 1.51 ± 0.011 ms 1 ± 0.011
forest/3D refined/halo_update! 2.9 ± 0.013 ms 0.803 ± 0.0056 ms 3.61 ± 0.03
forest/3D refined/halo_update_adjoint! 3.07 ± 0.018 ms 3.13 ± 0.03 ms 0.981 ± 0.011
grid/2D 256²/(∂x + ∂y)ᵀ adjoint 0.772 ± 0.0022 ms 0.772 ± 0.0027 ms 1 ± 0.0045
grid/2D 256²/2λ + κ·I mul! 0.114 ± 0.00064 ms 0.109 ± 0.00073 ms 1.04 ± 0.0091
grid/2D 256²/adjoint(∂x + ∂y) mul! 0.8 ± 0.0039 ms 0.808 ± 0.0042 ms 0.99 ± 0.007
grid/2D 256²/advection mul! 0.0713 ± 0.00074 ms 0.0684 ± 0.0013 ms 1.04 ± 0.023
grid/2D 256²/diffusion mul! 0.0947 ± 0.00044 ms 0.094 ± 0.00055 ms 1.01 ± 0.0075
grid/2D 256²/diffusion prepare 3.95 ± 3 μs 6.07 ± 5.6 μs 0.65 ± 0.78
grid/2D 256²/diffusion slab adjoint (β = 0) 0.0384 ± 0.00018 ms 0.0389 ± 0.00018 ms 0.986 ± 0.0065
grid/2D 256²/diffusion slab adjoint (β ≠ 0) 0.0978 ± 0.00072 ms 0.0988 ± 0.00071 ms 0.989 ± 0.01
grid/2D 256²/diffusion slab apply! 27 ± 0.15 μs 27 ± 0.13 μs 0.998 ± 0.0074
grid/2D 256²/divergence mul! 0.107 ± 0.00028 ms 0.11 ± 0.00045 ms 0.979 ± 0.0048
grid/2D 256²/divergenceᵀ adjoint (β ≠ 0) 0.226 ± 0.0036 ms 0.226 ± 0.0029 ms 1 ± 0.02
grid/2D 256²/gradient mul! 0.177 ± 0.0027 ms 0.177 ± 0.0027 ms 0.999 ± 0.022
grid/2D 256²/gradientᵀ adjoint (β ≠ 0) 0.382 ± 0.01 ms 0.382 ± 0.01 ms 1 ± 0.038
grid/2D 256²/laplacian mul! 0.0587 ± 0.00064 ms 0.0575 ± 0.00049 ms 1.02 ± 0.014
grid/2D 256²/laplacian prepare 4.17 ± 3.2 μs 5.8 ± 5.7 μs 0.719 ± 0.9
grid/2D 256²/∂x adjoint (β = 0) 0.379 ± 0.01 ms 0.378 ± 0.0098 ms 1 ± 0.037
grid/2D 256²/∂x adjoint (β ≠ 0) 0.405 ± 0.01 ms 0.404 ± 0.0099 ms 1 ± 0.035
grid/2D 256²/∇·(κ∇u) mul! 0.261 ± 0.0097 ms 0.262 ± 0.0096 ms 0.998 ± 0.052
grid/2D 256²/∇·(κ∇u) prepare 14.7 ± 12 μs 16.6 ± 12 μs 0.885 ± 0.96
grid/3D 64³/diffusion mul! 0.53 ± 0.011 ms 0.534 ± 0.011 ms 0.992 ± 0.028
grid/3D 64³/diffusion slab adjoint (β = 0) 0.539 ± 0.011 ms 0.538 ± 0.011 ms 1 ± 0.029
grid/3D 64³/diffusion slab adjoint (β ≠ 0) 0.883 ± 0.0038 ms 0.883 ± 0.0034 ms 1 ± 0.0058
grid/3D 64³/diffusion slab apply! 0.192 ± 0.0021 ms 0.189 ± 0.0013 ms 1.02 ± 0.013
grid/3D 64³/laplacian mul! 0.319 ± 0.01 ms 0.321 ± 0.01 ms 0.991 ± 0.045
grid/3D 64³/laplacian prepare 0.111 ± 0.053 ms 0.113 ± 0.029 ms 0.987 ± 0.53
time_to_load 0.293 ± 0.0025 s 0.293 ± 0.004 s 1 ± 0.016

Memory and allocations

bacc949... ab2664f... bacc949... / ab2664f...
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
forest/3D refined/halo_update! 0 allocs: 0 B 0 allocs: 0 B
forest/3D refined/halo_update_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²/diffusion slab adjoint (β = 0) 0 allocs: 0 B 0 allocs: 0 B
grid/2D 256²/diffusion slab adjoint (β ≠ 0) 0 allocs: 0 B 0 allocs: 0 B
grid/2D 256²/diffusion slab apply! 0 allocs: 0 B 0 allocs: 0 B
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³/diffusion slab adjoint (β = 0) 0 allocs: 0 B 0 allocs: 0 B
grid/3D 64³/diffusion slab adjoint (β ≠ 0) 0 allocs: 0 B 0 allocs: 0 B
grid/3D 64³/diffusion slab apply! 0 allocs: 0 B 0 allocs: 0 B
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.149 k allocs: 11.2 kB 0.149 k allocs: 11.2 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).

@kylebeggs
kylebeggs added this pull request to stack #99 September 10, 2026 20:44
@kylebeggs
kylebeggs force-pushed the perf/fused-gather-fills branch from 5155be4 to 36e4109 Compare September 10, 2026 20:49
@kylebeggs
kylebeggs removed this pull request from stack #99 September 10, 2026 20:49
@kylebeggs
kylebeggs added this pull request to stack #100 September 10, 2026 20:50
@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-09-11 12:43 UTC

Base automatically changed from perf/ghostfill-isbits to main September 11, 2026 12:36
The host fill sweeps ran one broadcast per CSR term — 7 per fill in 2D, 19
in 3D — over a handful of dst cells each, so broadcast setup, not
arithmetic, dominated the exchange on a refined forest. Evaluate the whole
weighted sum per cell inside a single broadcast instead: the K term views
are built once per fill by recursion over Val(K) down the row (closure-free,
so the two-array walk keeps inlining) and ride an immutable Ref subtype as a
broadcast scalar, which a mutable RefValue cannot do without escaping the
un-inlined gather body and heap-allocating ~2 KB per 3D fill inside a rule
seam that must not allocate.

halo_update! on a refined 32³ forest: 853 → 205 µs packed and 751 → 229 µs
per-block at 8³ blocks, 3.0 → 0.67 ms at 4³ (min-of-N, alternating
processes, three rounds, ±4%).

Term and accumulation order are unchanged, so the result is bit-identical
to the retired loop whenever the field eltype is the schedule's weight type,
and — unlike that loop, which rounded its partial sum into the field K−1
times — bit-identical to the device CSR kernel in mixed precision too.

_close_fill now asserts the two row invariants the fusion rests on: every
term window has the dst slab's shape, and every term window is disjoint from
the dst slab (the views ride a non-AbstractArray scalar, so Base's aliasing
machinery never sees them).

The adjoint keeps its per-term scatter: a fill's term windows collide on
shared source cells, so a dst-centric single pass would reorder the
accumulation. The bit-identical fused route is a source-centric transposed
CSR, scoped to #94.

Claude-Session: https://claude.ai/code/session_016F4h1y22x3ohpRdGVCpjHP
…cally

A bit-parity testset alone cannot see a revert: its reference is a
re-written copy of the per-term loop the fusion replaces, and the
zero-allocation testset passes either way. Redefining _run_fills! back to
the loop left the suite green.

So count what the sweep does. ProbeArray is block storage that tallies every
element write and every broadcast materialized into a view of it; one phase
run over it must issue exactly one broadcast per fill and write every dst
cell exactly once. A per-term loop over a K-wide row issues K and writes K
times, and now fails.

Also added: bit-parity against the retired loop in Float32 and Float64, 2D
and 3D, both storage layouts, scalar and SVector fields, forward and
adjoint; a mixed-precision case (Float32 field on a Float64 forest) checked
against the device CSR kernel on the CPU backend, where the fused gather is
exact and the retired loop differed on 73 cells in 2D and 4420 in 3D; the
row invariants _close_fill asserts, on hand-built rows and on every row the
emitters produce; and zero allocation for both layouts, forward and adjoint,
on refined 2D and 3D forests.

Claude-Session: https://claude.ai/code/session_016F4h1y22x3ohpRdGVCpjHP
What the fusion buys, what it is bit-identical to (the retired loop at
matched eltypes; the device kernel always, which the loop was not), the two
row invariants that make it legal, the compile-latency price, and why the
adjoint stays per-term until #94.

Claude-Session: https://claude.ai/code/session_016F4h1y22x3ohpRdGVCpjHP
@kylebeggs
kylebeggs force-pushed the perf/fused-gather-fills branch from 36e4109 to ab2664f Compare September 11, 2026 12:37
@kylebeggs
kylebeggs merged commit 427b573 into main Sep 11, 2026
10 checks passed
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.

perf(amr): fuse _run_fills! into one gather pass per coarse-fine fill

1 participant