Reducing spherical-harmonic transforms in #
Summary. Algorithm 4 costs 12 transforms per matvec (8 syntheses, 4 analyses). A flux-form
reformulation, with weights chosen so that every analyzed field is smooth on , evaluates the
same operator in 6 transforms (4 syntheses, 2 analyses), or 7 with the divergence split
against the round sphere that §5 turned out to require. Notation follows algos.pdf.
1. Where the current cost comes from#
| Algorithm 4 line | Work | Transforms |
|---|---|---|
| 1 | 2 | |
| 5 | analysis of 3 Cartesian components of | 3 |
| 6 | and of each of those 3 components | 6 |
| 8 | final analysis | 1 |
| 12 |
Two sources of waste:
- The gradient is carried in ambient components — 3 fields for an intrinsically 2-dimensional object.
- Line 6 takes both derivatives of each component, where the divergence needs only one derivative of each of two fluxes.
There is also a possible free win independent of everything below: Algorithm 1 as specified returns
all five derivatives. The Laplacian path needs only and ;
the second-derivative and mixed-derivative machinery is used exclusively by Algorithm 3 (curvature).
If surface_screened_laplacian calls Algorithm 1 wholesale, it is doing 5 syntheses where 2 suffice
at lines 1 and 6.
2. The constraint that shapes the solution#
The Cartesian design in Algorithm 4 exists to avoid pole singularities, and it is correct to do so. The relevant property is smoothness as a scalar function on , since that is what controls SH coefficient decay and hence whether is meaningful.
| Quantity | Smooth on ? |
|---|---|
| yes — exactly band-limited, eq. (2.2) | |
| yes — exactly band-limited, eq. (2.4) | |
| no — bounded, but -dependent limit at the poles | |
| no in general | |
| yes |
Concretely, for the ellipsoid , as : no limit exists.
Algorithm 4 never analyzes anything in the "no" rows — the non-smooth quantities appear only as pointwise grid factors. Any replacement must preserve this property. The naive flux form , does not: on the round sphere with , , which is not a function on .
The correct weighting#
and are times the contravariant components of . Using :
Every factor on the right is smooth on : smooth, smooth, smooth, and smooth because it is exactly band-limited by the recurrence already implemented. Hence and are analyzable, on the same footing and for the same structural reason as the Cartesian gradient components.
The cross products are the smoothness certificate only — they are not needed in the code.
3. Precompute (once per surface, grid space)#
Replaces _precompute_metric_quantities(). From the embedding coefficients , obtain
and componentwise via Algorithm 1, where the latter is the
undivided output of Algorithm 1 line 4, i.e. with no .
Then, pointwise:
(The radicand is , so the square root is — hence , not , in the denominator.)
Store four scalar grid arrays:
All four are bounded: the denominators cancel against vanishing numerators (, ), the same finite limits the current have. Note are bounded where is not.
Three scalar arrays replace the six components of . The surface representation is unchanged: still come componentwise from via Algorithm 1.
4. The per-matvec algorithm#
Input ; output .
| # | Step | Transforms |
|---|---|---|
| 1 | ; | |
| 2 | ||
| 3 | , — pointwise | — |
| 4 | , | 2 |
| 5 | — | |
| 6 | ||
| 7 | ; zero |
4 syntheses + 2 analyses = 6, versus 12.
- and are exactly and .
- Steps 1 and 5 use the same precomputed table; step 5 is the adjoint-style reuse of the shift already implemented for step 1. Adding the two flux contributions in coefficient space before synthesizing is what saves the final pair of transforms.
- The only division by anywhere is folded into at precompute time. The per-matvec path contains none.
Implemented variation (2026-08-05): the -flux never needs the Legendre basis#
Step 4's analysis of exists only so step 5 can apply — but is diagonal in the Fourier index, so the implementation differentiates on the grid instead: FFT each latitude row, multiply mode by (zeroing to mirror the top-degree filter; the Fourier analysis stage truncates for free), inverse FFT. 5 Legendre transforms + one Legendre-free FFT derivative, versus 6. The caveat is that the grid route skips 's band projection in ; measured, this does not bite — the band-edge spectra are identical to the 6-transform route's (the mask and the final analysis's projection contain it), the Algorithm-4 A/B agreement is unchanged ( after 20 steps at ), and the step gets ~8% faster at (~2% at , where transform batching had already amortized most of what this removes).
5. Numerical trade-off#
Both schemes contain two powers of division in total. What differs is placement.
- Algorithm 4 spends them in separate stages, one before line 5 and one after. The intervening analysis suppresses the polar spike: Gauss–Legendre weights give at the polar ring, so a grid error of there contributes to any coefficient. Stage 2 then starts from clean coefficients and incurs a fresh . The two amplifications never multiply. Net grid-space relative error: .
- The new scheme has no division at all through step 5, then pays for both powers at once in at step 6 — one event, with no intervening analysis to break it in half. Net grid-space relative error: .
The mechanism: with Gauss–Legendre nodes, so . Since is at the polar ring but over the bulk, and synthesis commits roundoff scaled by the field's global size at every node alike, multiplying by recovers the signal and inflates the noise.
| divisions | placement | grid-space relative error | |
|---|---|---|---|
| Algorithm 4 | , | separated by | |
| Six-transform | all at the end |
It does reach the returned coefficients, and it matters. The suppression argument above is right as far as it goes — step 7's analysis knocks the spike down to , and this document originally concluded from that the extra power would be invisible inside the solve. It is not, and the reason is not about accuracy. Measured on the default ellipsoid at : starting from the exact uniform steady state, three Richardson iterations per step leave a standing coefficient-space perturbation the no-correction floor ( vs ), against for Algorithm 4. That perturbation is static, polar, and re-injected every step. In a Turing problem the pattern is seeded by whatever is largest in the unstable band, so a forcing four orders below the field selects the nucleation site: the run grows a spot at the pole, on every seed, regardless of the initial condition.
The fix is to keep off the round sphere. Write , ( is already zero on the sphere). The sphere's share of the divergence is the cancelling part, and it is known in closed form: , and is diagonal. So
with bounded. Only the geometry deviation now meets the concentrated
division. Cost: one extra synthesis per species per iteration for — 7 transforms, not
6 — which batches into the gradient's existing grouped call and measures at ~10% of a step, against
for reverting to Algorithm 4. and must be formed in float64 at
precompute time (src/geom/geometry.ts): on a near-sphere they are the small quantity, and
subtracting 1 in float32 on device would lose them.
Measured against Algorithm 4 through a real run (relative of at , ):
| plain flux | sphere-split | |
|---|---|---|
| ellipsoid, | ||
| blob, | ||
| ellipsoid, |
and the polar noise gain is asserted in test/fluxChecks.ts, which fails at on the
unsplit form.
The residual still applies to grid values of consumed directly — a nonlinear reaction term, max-norm diagnostics, an adaptive error estimator — for the deviation part alone.
Algorithm 1 line 7 already divides by , so the code is exposed to today — just on the second-derivative path, which the Laplacian never touches.
5a. float32 / WebGPU#
Target is WebGPU, which is float32-only: (spacing ). No float64 fallback exists on device. All estimates in §5 are linear in , so they scale directly:
| (coeffs) | (Alg. 4 grid) | (new, grid) | |
|---|---|---|---|
Coefficient space is fine (~), which is the floor a float32 iterative solve sits at anyway. Grid space is not: 0.1–0.4% relative on the polar rings at . For a reaction–diffusion solver this matters only if grid-space is consumed outside the matvec. If the IMEX splitting evaluates from on the grid (typical), it never is.
Two float32-specific arguments in favour of the new scheme:
- Baseline SHT roundoff accumulates per transform ( to each). Running 6 transforms instead of 12 halves that accumulation. On the coefficient-space error GMRES actually sees, this plausibly outweighs the polar term — the new scheme may be more accurate end-to-end in float32. Not asserted without measurement.
- 3 weight arrays instead of 6 halves per-matvec texture/buffer traffic. On GPU that is often the real bottleneck, independent of arithmetic.
Mitigations available without float64:
- Pairwise or blocked summation in the Legendre sum over . The single highest-value float32 change, and it benefits the existing code too. See §5b.
- Double-float (
f32x2) arithmetic for the pointwise steps 3 and 6 if needed — cheap, no transforms involved. Does not help with transform roundoff, which is the dominant term, so try summation order first. - CPU precompute in float64. JS
Numberis float64, so §3 can run on the CPU regardless of WebGPU's limits, with float32 weights uploaded. Cost is CPU-side SHTs plus upload, paid once per surface update; viable if the surface evolves slowly or is prescribed analytically, likely too slow if the metric is rebuilt every timestep. Per the correction below, this is probably unnecessary. - Cap . All the error terms grow with ; float32 sets a practical ceiling that float64 would not.
5b. Summation order in the Legendre transform#
This is orthogonal to the 12→6 change, applies equally to the current code, and in float32 is probably worth more than the transform-count reduction. Do it first and independently, so its effect can be measured on its own.
Why. Every and in §5 rides on the per-transform roundoff floor, and in float32 that floor is set by how the sums are accumulated, not by the mathematics. For each the synthesis evaluates
an -term sum. Error growth by accumulation strategy, for an -term sum:
| Strategy | Worst case | Typical (random signs) |
|---|---|---|
| Sequential | ||
| Pairwise / tree | ||
| Kahan compensated | (+ ) |
At in float32 that is the difference between and per transform — more than an order of magnitude, for no change in operation count.
On GPU this may already be partly free. A workgroup tree reduction over is pairwise
summation. The failure mode is a serial for loop over inside a single thread, which is the
natural way to write the shader if each thread owns one pair and is exactly the
row above. Check which shape the kernel has before assuming anything.
Where it applies.
- Synthesis : the sum over , as above. The -direction FFT is already tree-structured and needs no attention.
- Analysis : the quadrature sum over latitude nodes carries the identical problem and the identical fix. It also matters more here, because this is the step relied on in §5 to suppress the polar spike — a noisy quadrature sum weakens exactly the mechanism the six-transform scheme depends on.
Practical notes.
- Blocked summation (accumulate in blocks of 8–32, then combine) captures most of the pairwise benefit with a simpler kernel and better register behaviour than a full tree.
- Kahan costs ~4 flops per term and is usually bandwidth-hidden on GPU; worth benchmarking rather than assuming it is too expensive.
- For near the poles, spans many orders of magnitude across . Summing smallest-magnitude-first helps, and is nearly free here because the terms are already roughly ordered by .
- Standard stable recurrences for (and guarding their under/overflow in float32's narrower exponent range) are a separate prerequisite — no summation strategy rescues inaccurate Legendre values.
Measurement. Transform a band-limited field forward then back and compare to the input, in float32, sweeping . Sequential accumulation shows error growing roughly linearly in ; pairwise shows near-flat growth. This isolates the transform floor from everything else in §7 and should be run before the validation gate there, since it sets the baseline that gate is measured against.
Measured (2026-08-04, Dawn/Metal, scripts/sht-accuracy.ts)#
The sweep was run and the summation-order changes tried. Outcome: withdrawn — the floor here is not summation-limited.
| grid | rel- roundtrip | worst degree | |
|---|---|---|---|
| 63 | 64×128 | : | |
| 127 | 128×256 | : | |
| 255 | 256×512 | : |
- The analysis side already sums pairwise. The quadrature over latitudes is a workgroup
tree/subgroup reduction (
leg_analys); only the synthesis has the serial per-thread -loop. - Kahan is unavailable on WebGPU in practice. Dawn/Metal compiles WGSL with fast-math: a probe kernel evaluates to , so the compensation folds away and Kahan compiles to plain summation (bit-identical results, verified).
- Blocked summation (B=16) in the synthesis -loop moved nothing: at (low digits shift, confirming the reordering was live), while costing ~5% per round trip at . Reverted.
- Diagnosis: the worst error concentrates at the top degrees — the signature of the Legendre recurrence error (chains of length ), not of -uniform accumulation noise. This is the "standard stable recurrences are a separate prerequisite" caveat above: the floor is set by the accuracy of the values themselves, and no summation strategy touches it.
- The measured floor (, at ) is what the §7 validation gate should be read against.
6. Code changes#
| Location | Change |
|---|---|
src/surface_gradient/partial_derivatives |
Expose pre-division (flag or separate entry point). Needed by both the precompute and step 1. |
SurfaceDiffOperator._precompute_metric_quantities() |
Return p1, p2, q2, r instead of V_theta, V_phi, per §3. |
src/surface_screened_laplacian::surface_screened_laplacian() |
Replace body with §4. Both for i in {x,y,z} loops disappear. |
SurfaceDiffOperator._precompute_curvature(), Algorithm 3 |
Unchanged. Still needs and the full Algorithm 1. |
src/timestepping::make_implicit_op(), Algorithm 5 |
Unchanged. Only what line 8 calls changes. |
src/real_embedding.py |
Unchanged. |
The deprecated SurfaceDiffOperator methods for and are the
natural place to keep the old path as a reference implementation for the validation below.
Correction: precompute conditioning#
An earlier draft claimed the polar relative error in is , making float64 precompute essential. That was wrong by a factor of , in the safe direction. is not synthesized directly; it is the square of , which is synthesized, is at the poles, and carries absolute error — so relative error , preserved (up to a factor 2) by squaring. Same for and . The determinant combination is and so are both of its terms, so there is no extra cancellation generically; inherits , i.e. at in float32. Acceptable.
Caveat: this assumes the difference is not small compared to its terms, which fails if becomes nearly parallel to (near-degenerate parametrization). Worth a runtime check on if the surface can deform that far.
Precompute error is also a fixed perturbation, identical every matvec, so it perturbs which operator is being solved but injects no noise into the Krylov space — GMRES converges normally.
7. Validation, in order#
At there is no margin for the suppression estimate in §5 to be off by an order of magnitude. Step 2 is a gate, not a confirmation.
- Smoothness check (do this first). On a deformed, non-axisymmetric surface, form and on the grid and compare their SH coefficient decay against from the current code. Matching tails confirm both are genuinely smooth on . If this fails, nothing else is worth doing. Run this in float64 on CPU — it is a mathematical check, not a precision one.
- Coefficient-space diff in float32 at production , against a float64 CPU reference implementation of Algorithm 4. Landing near means the suppression argument holds. Landing near means the polar spike is surviving the analysis and the polar rings need separate handling.
- Sweep and fit the growth exponent of (2). Flat-ish confirms suppression; growth like means it is not working.
- Grid-space max-norm diff near the poles. Expect the extra power of here. If only this grows and (2) stays flat, the scheme is fine for use inside the implicit solve.
- GMRES iteration count and final achieved residual, float32, versus the current code. The operator is the same, so iterations should be unchanged; a stall above tolerance that does not occur in float64 indicates the matvec noise floor is binding.
8. Suggestions considered and withdrawn#
- Splitting across steps 3 and 6 to keep scaling. Does not work: is not smooth on (round sphere, : , so at the pole). That must stay in . Algorithm 4 can split because its intermediate — the Cartesian gradient — is smooth; that smoothness is precisely what the six extra transforms buy.
- Weighting by to get an SPD operator and use PCG. Avoiding the makes self-adjoint, but the mass term becomes multiplication by , costing its own synthesis/analysis pair. A wash on transform count; worth it only if the CG properties themselves are wanted. Discrete symmetry would also hold only to quadrature accuracy unless products are dealiased (3/2 rule).
- Mixed precision (float64 for step 6's synthesis and the multiply only). Unavailable: WebGPU is float32-only. Superseded by the mitigations in §5a.
- If the ever does bind, the remaining remedies are a shifted or uniform-in- latitude grid (removing the node clustering) or a separate local formula for the polar rings — both more work than the transform savings justify without a specific reason. In float32 the ceiling on may bind first and be the cheaper accommodation.
9. Related: external vector transforms#
Steps 1–2 and 4–5 together are a vector/spin-weighted spherical harmonic transform. If SHTns
(spat_to_SHsphtor, SHsphtor_to_spat) or SPHEREPACK (gradgs, divgs) can be linked, the
gradient and divergence each become a single library call, the pole divisions are handled internally,
and the hand-rolled recurrences on this path are no longer needed.
10. Beyond transform count#
The other lever is iteration count rather than cost per iteration. Since approaches multiplication by at high , folding a mean or smoothed into the preconditioner could reduce GMRES iterations by more than any of the above reduces transforms.
Measured (2026-08-05, Richardson iteration, fp32)#
Implemented, with two corrections the measurements forced.
The right constant is the minimax over the symbol, not over . The high- per-mode factor is governed by the full principal symbol: in the orthonormal frame the symbol matrix is , whose eigenvalues are the inverse squared principal stretches — direction matters. Preconditioning with contracts every mode and direction iff , so
The det-based mean of (this section's original suggestion; 's geometric mean, exact only for conformal surfaces) is insufficient: on the shipped ellipsoid it leaves directional high-degree bands with amplification — patterns went qualitatively high-frequency at moderate settings and diverged as niter or grew. With the symbol-based constant every niter/geometry combination in the test sweep converges (peanut: , plain rate 5.2, preconditioned rate 0.87).
The correction must be band-projected. Algorithm 5's "zero " is load-bearing: without applying the same mask to the correction , the top two degrees iterate toward the undiffused right-hand side — each Richardson iteration strips more of their implicit diffusion, at species-dependent rates, manufacturing a spurious Turing band at the band edge (observed on the round sphere: top-degree energy growing /step at , 8 iterations).
Payoff shape: on mildly deformed surfaces one iteration already reaches the fp32 accumulation floor, so iteration counts do not drop — the speculation above does not hold at fp32. The gain is reach and correctness: stiff geometries and high niter/ combinations that previously diverged (or silently shifted the pattern's wavelength) now converge with a resolution-independent spectrum.