Make the spectral-element operators dimension generic - #2559
Open
imreddyTeja wants to merge 5 commits into
Open
Conversation
`Divergence`, `SplitDivergence`, `WeakDivergence`, `Gradient`, `WeakGradient`, `Curl` and `WeakCurl` each had separate one- and two-dimensional implementations of the same tensor-product stencil, on both the CPU (`apply_operator`) and the GPU (`operator_evaluate` plus the shared-memory allocation and fill), and each two-dimensional method repeated its one-dimensional counterpart once per axis. Each operator now has a single implementation that loops over the axes it works over, with the axis index carried in the type domain so the loop unrolls and the node indices stay statically sized. New helpers in `src/Operators/spectralelement.jl` carry the shared pieces: `axis_vals`, `slab_dims`, `replace_index`, `contravariant`/`covariant`, `covariant_vector`/`covariant_basis_vector`, and, for the curls, `curl_uses_component`/`curl_covariant_components`/`curl_term` (one Levi-Civita helper serves both `Curl` and `WeakCurl`, which negates the coefficient). On the GPU the shared memory is described by `shmem_dims`, `sem_shmem`, `sem_shmem_per_axis` and `shmem_index`, and the kernels reduce to two stencils, `apply_stencil` (weak operators pass `D'`) and `curl_stencil`. Two annotations in the per-axis closures matter for performance: `@inline`, without which the mutable slab temporary escapes and is heap-allocated once per slab (1.1-3.8x slower), and `@inbounds`, which a closure body does not inherit from the enclosing block. Results are bit-for-bit unchanged on the CPU across 74 operator expressions over one- and two-dimensional, bare and extruded, rectilinear and cubed sphere spaces, with allocations and timings unchanged. GPU results agree to roundoff (<= 3.1e-15, from cross-axis reassociation and FMA selection) and kernel timings are within noise. Also fixes the spectral-element operators failing to run on the GPU over a `SpectralElementSpace1D`: the space could not be passed to a kernel (`KernelError: passing non-bitstype argument`) because, unlike `SpectralElementGrid2D`, `SpectralElementGrid1D` had no device-side counterpart to convert to. Adds `Grids.DeviceSpectralElementGrid1D` and the corresponding `Adapt` rules, which also makes `to_device` work on a `SpectralElementSpace1D`, where it previously returned the space unchanged. `test/Operators/spectralelement/plane_cuda.jl` covers the one-dimensional GPU path, which had no test before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0148rCSSVswyipJTZjqpEUMd
imreddyTeja
force-pushed
the
tr-Claude/spectral-ops
branch
from
July 30, 2026 19:52
850c103 to
8804cf0
Compare
The axis loop was written two ways: the CPU operators called `unrolled_foreach(axis_vals(op))` inline at five sites, each repeating the `@inline` annotation and a comment explaining it, while the CUDA extension had its own `sum_over_axes` plus a bare `unrolled_foreach(axis_vals(op))` in the shared-memory fills. Both now go through `foreach_axis` and `sum_axes`, defined once next to `axis_vals`, so the axis-tuple plumbing and the reason the per-axis accumulators are kept separate are stated in one place. The `@inline` stays in each closure body rather than moving into the helpers: only a method, not an anonymous function, can carry it, and forcing the inline from inside the helper -- by wrapping `f` in another closure to annotate its call site -- reintroduces the per-slab heap allocation it exists to prevent (I measured every operator kernel allocating again). The helpers' docstring now carries that explanation, along with the matching note about `@inbounds`. Verified: CPU results bitwise unchanged (74/74 expressions) with allocations back at zero for all 14 benchmark kernels; GPU results bitwise identical for all 70 supported expressions. Tests pass on both devices. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0148rCSSVswyipJTZjqpEUMd
`covariant_components`, `covariant_vector` and `covariant_basis_vector` were
defined in `src/Operators/spectralelement.jl` because that is where they were
first needed, but they are pure geometry: they build a `Components{Covariant, I}`
and a `Tensor` over it, with no operator content. They now sit in
`src/Geometry/tensors.jl` next to the `CovariantNAxis` / `CovariantNVector`
aliases whose generic forms they are.
Two changes came with the move:
- They take the dimensions as `Val{I}` rather than an operator, matching
`gradient_result_type(::Val{I}, ...)` and `curl_result_type` in the same
module. The three spectral-operator call sites pass `Val(I)`, which they
already have as a type parameter.
- `covariant_components` is renamed `covariant_axis`, so that it reads as the
generic form of the `Covariant12Axis()` spelling used elsewhere in Geometry;
"components" was ambiguous with the component *values*.
Adds a Geometry testset covering the documented equivalences, including that the
axis index in `covariant_basis_vector` is a dimension label rather than a
position (`Val((2, 3)), Val(3)` selects the second component).
Verified: CPU results bitwise unchanged (74/74 expressions), allocations still
zero across the 14 benchmark kernels, GPU results bitwise identical for all 70
supported expressions, and no new method ambiguities. Geometry, spectral element
and hybrid tests pass on both devices.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0148rCSSVswyipJTZjqpEUMd
Interpolate and Restrict had one apply_operator per dimension each, four methods that differed only in the number of axes contracted and in three factors: the matrix contracted along each axis, whether the argument is weighted by WJ, and whether the result is divided by it. Those factors are now tensor_matrix, tensor_weighted_arg and tensor_rescale -- the shape the form_* helpers already use for strong versus weak -- over one implementation that folds contract_axis! over the operator's axes, contracting one at a time into the temporaries contract_temps allocates. A single-axis contraction gets no temporary, as the old one-dimensional methods had none. tensor_product! folds with the same primitives, so rmatmul1 and rmatmul2 are gone (they had no other callers), and one method now covers all three of its output layouts instead of four. Its invariants move from dispatch into assertions, since one method cannot express "square only if the input has a second node axis". CPU results are bitwise unchanged for 73 of the 74 checked operator expressions, with zero allocations across all 14 benchmark kernel/space combinations; GPU results are unchanged for all 70 supported expressions. The exception is Restrict on a SpectralElementSpace1D, where 3 of 15 nodes move by at most 1 ulp: LLVM contracts a different subset of the contraction's muladds into fmas once the loop over output nodes is written generically. The order of summation is unchanged, verified by reproducing every value with a fixed per-step contraction pattern. Adds a unit test for tensor_product!, whose VIJFH output layout and in-place form had no coverage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
get_node, set_node!, get_local_geometry and slab_node_index each had a one- and a two-dimensional method that differed only in padding the unused node axis with 1 and, for the accessors, in repeating the face/center level staggering. The padding is now data_index and the staggering is slab_level_index, so each is one method. node_indices likewise becomes one definition keyed on the number of operator axes, plus the FiniteDifferenceSpace case. Two deliberate consequences. The two-dimensional get_node and set_node! now accept a bare Center/FaceFiniteDifferenceSpace, which only their one-dimensional counterparts did; that case is load-bearing, since a horizontal operator over an extracted column space reaches these accessors with a one-dimensional node index over such a space. And get_local_geometry and set_node! now raise "invalid space" for a space of unknown staggering rather than silently reading level slabidx.v, as get_node already did. Results are bitwise unchanged from the previous commit on both devices, with zero allocations across all 14 benchmark kernel/space combinations. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
The spectral-element operators had separate 1D (
{(1,)}) and 2D ({(1, 2)}) implementations of the same tensor-product stencil, with each 2D method repeating its 1D counterpart once per axis. This was true on both sides:apply_operatorinsrc/Operators/spectralelement.jloperator_evaluateinext/cuda/operators_spectral_element.jl, plusoperator_shmem/operator_fill_shmem!inext/cuda/operators_sem_shmem.jlEach of
Divergence,SplitDivergence,WeakDivergence,Gradient,WeakGradient,CurlandWeakCurlnow has a single implementation that loops over the axes it works over, with the axis index carried in the type domain so the loop unrolls and the node indices stay statically sized.The last two commits then remove the rest of the per-dimension duplication in the same file —
Interpolate/Restrict,tensor_product!, and the node accessors — described in its own section below.This removes 78 lines from
src+ext(850 added, 928 removed). Counting only code -- excluding blank lines,#comments and docstrings -- it is 389 lines, a 14% reduction (2752 -> 2363); the refactors account for -412 code lines and the GPU bug fix plus theGeometrymove add 23 back. The operator methods themselves go from 37 to 18:ext/cuda/operators_sem_shmem.jl(operator_shmem,operator_fill_shmem!)ext/cuda/operators_spectral_element.jl(operator_evaluate)src/Operators/spectralelement.jl(apply_operator)GeometrymoveThe six remaining
apply_operatormethods are the four unified differential operators, one forInterpolateandRestricttogether, and the zero-axes fallback.Shared helpers (documented in a "Dimension-generic building blocks" section):
axis_vals(op)(Val(1), Val(2))for the axesopworks overslab_dims(op, Nq)Nqper axisreplace_index(index, Val(d), k)d, or shrink a contracted axis' extentcontravariant/covariant(Val(d), u, lg)covariant_vector/covariant_basis_vectorcurl_uses_component,curl_covariant_components,curl_termCurlandWeakCurl, which just negates the coefficientforeach_axis/sum_axescontract_axis!/contract_axes!/contract_tempsdata_index,slab_level_indexshmem_dims,sem_shmem,sem_shmem_per_axis,shmem_indexapply_stencil,curl_stencilD')Two annotations in the per-axis closures matter for performance and are commented in the code:
@inline, without which the mutable slab temporary escapes and is heap-allocated once per slab (measured 1.1–3.8× slower), and@inbounds, which a closure body does not inherit from the enclosing block.The rest of the duplication: contractions and node accessors
The differential operators above accumulate an independent contribution per axis, so one pass over a slab covers every axis.
Interpolate,Restrictandtensor_product!instead contract their axes sequentially — contract axis 1 into a temporary, then contract axis 2 out of it — which is why they were left per-dimension in the first three commits. They fold over the axes rather than looping over them:contract_axis!(dst, M, src, post, Val(d), dims_out)contracts one axis against a matrix,contract_axes!folds it over a tuple of axes, each contraction reading what the previous one wrote and the last writing into the output,contract_tempsallocates thelength(I) - 1intermediates, so a single-axis contraction gets no temporary at all — which is what the old 1D methods did by hand.On top of that,
InterpolateandRestrictdiffer only in three factors, in exactly the shape #2556 used for strong vs. weak:tensor_matrix(the matrix, transposed forRestrict),tensor_weighted_arg(WJ-weighted forRestrict) andtensor_rescale(divided by the outputWJforRestrict). So four methods become one.tensor_product!folds with the same primitives, which letsrmatmul1/rmatmul2go (they had no other callers), and covers all three of its output layouts in one method instead of four.The node accessors had the same 1D/2D split, differing only in padding the unused node axis with 1 and in repeating the face/center level staggering. Those are now
data_indexandslab_level_index:apply_operatorforInterpolate/Restricttensor_product!get_nodeset_node!,get_local_geometrynode_indicesTwo deliberate behaviour changes for downstream code, both in
NEWS.md:get_nodeandset_node!now accept a bareCenter/FaceFiniteDifferenceSpace, which only their one-dimensional counterparts did. The asymmetry looks incidental (a 2D node index never arises over such a space) but the 1D case is load-bearing: a horizontal operator over an extracted column space reaches these accessors that way.get_local_geometryandset_node!now raise"invalid space"for a space whose staggering is not recognised, instead of silently reading levelslabidx.v.get_nodealways did. The full CPU and GPU suites pass with the strict version, which is the evidence that nothing relied on the permissive one.tensor_product!'s invariants also move from dispatch into assertions (square node axes in and out; anIH1JH2output takes a single level), since one method cannot express "square only if the input has a second node axis". A mismatched call now fails an assertion rather than raisingMethodError.Bug fix:
SpectralElementSpace1Don the GPUSpectral-element operators could not run on the GPU over a bare
SpectralElementSpace1Dat all — the space could not be passed to a kernel (KernelError: passing non-bitstype argument, since.gridholds anIntervalTopologywith a mesh and domain). UnlikeSpectralElementGrid2D,SpectralElementGrid1Dhad no device-side counterpart to convert to. This addsGrids.DeviceSpectralElementGrid1Dand the correspondingAdaptrules.Side effect:
to_devicenow works on aSpectralElementSpace1D. It previously hitAdapt's identity fallback and returned the space unchanged.Testing
New
test/Operators/spectralelement/plane_cuda.jl(with aUnit: plane cudapipeline entry) covers the 1D GPU path, which had no test before: all seven operators plusdiv∘gradandcurl∘curl(which force two operators to share shared memory) over a bareSpectralElementSpace1Dand an extruded 1D space, compared against the CPU, plus two absolute value checks. With only the fix reverted it fails 11/11 on the bare 1D space and passes 11/11 on the extruded one, isolating the bug.New
test/Operators/spectralelement/unit_tensor_product.jlcoverstensor_product!against an explicitM * x * M'for all three of its output layouts and for the in-place two-argument form. TheVIJFHoutput (used by ClimaCorePlots) and the in-place form had no coverage at all before, and both are rewritten here; only theIH1JH2andVIH1outputs were reached indirectly, throughmatrix_interpolate.Verification beyond the test suites, comparing every operator over 7 spaces (1D/2D, bare/extruded, rectilinear/cubed sphere; 74 expressions) against results captured before the refactors:
main, zero allocations across all 14 kernel/space combinations, kernel timings 0.985–1.018×. The one exception isRestricton aSpectralElementSpace1D, where 3 of its 15 nodes move by at most 1 ulp (2.2e-16 on values of order 1). The order of summation is unchanged; LLVM simply contracts a different subset of the contraction'smuladds intofmas once the loop over output nodes is written generically. Verified by reproducing every value with an explicit chain whose kthmuladdis contracted iffmask[k]: one consistent mask reproduces all 15 values, which is what distinguishes "LLVM chose differently" from a reassociated sum. Two candidate fixes (applying the rescale in a second pass over the output; fusing it back into the last contraction) changed nothing, so the fused form was kept because it matches the structure of the code it replaces.mainand this branch, 40 are bitwise identical and 10 (2DDivergence,SplitDivergence,Gradient,Curl) differ by at most 5.3e-15 on values of order 1–20, from reassociation and FMA selection after restructuring the accumulation loops; the GPU is deterministic run to run. The 20 bare-1D expressions that previously could not compile all agree with the CPU. All 70 supported expressions are bitwise unchanged across the last two commits, which touch the accessors the GPU kernels share.wgrad_divwas 9–11% slower and looked reproducible across two after-runs; sampling the baseline a second time showed the same kernel 1.07× slower than itself anddiv_grad0.93×.minimum(trial).timesuppresses within-trial noise but not between-session variation.Test files run green on both devices:
opt.jl(JET),rectilinear.jl,plane.jl,split_divergence.jl,unit_form_types.jl(added by #2556),unit_tensor_product.jl,covar_deriv_ops.jl,unit_diffusion2d.jl,sphere_{gradient,divergence,curl,diffusion,diffusion_vec,geometry,sphericalharmonics}.jl,unit_sphere_hyperdiffusion{,_vec}.jl,hybrid/unit_2d.jl,Fields/unit_field.jl,Geometry/tensors.jl; and on GPUplane_cuda.jl,rectilinear_cuda.jl,covar_deriv_ops.jl,hybrid/{unit_cuda,extruded_3dbox_cuda,extruded_sphere_cuda}.jl.detect_ambiguitiesis unchanged (40, all pre-existing, none naming anything added here).Note:
test/Spaces/unit_spaces.jl"2D spaces with mask" errors on GPU (aDivergenceF2Ckernel throwsDomainErrorfromsqrt(-1)where the test expects NaNs). Reproduced identically with this branch's changes stashed, so it is pre-existing and unrelated.Commits
The five commits are separately verified, in the order they were written:
8804cf06make the differential operators dimension generic (CPU and GPU)4ccf31f8share oneforeach_axis/sum_axespair between the CPU and GPU operatorscaeb50damove the generic covariant constructors intoGeometry7d8a835afold the tensor-product operators (Interpolate/Restrict,tensor_product!) over their axesef1be22ecollapse the per-dimension node accessors andnode_indices🤖 Generated with Claude Code
https://claude.ai/code/session_0148rCSSVswyipJTZjqpEUMd