Skip to content

Honour phi_scale end to end, guard truncating rotations, drop the dead FFT cache - #53

Open
subhk wants to merge 3 commits into
mainfrom
audit/port-to-main
Open

subhk wants to merge 3 commits into
mainfrom
audit/port-to-main

Conversation

@subhk

@subhk subhk commented Sep 13, 2026

Copy link
Copy Markdown
Member

Full-codebase audit fixes, ported onto current main. Every change is
behaviour-preserving under the default :dft configuration; the breaking moves
are confined to :quad and to hand-built configurations.

Correctness

phi_scale was only half-implemented. synthesis scales its Fourier bins by
phi_inv_scale(cfg) while analysis applied a fixed cfg.cphi, so under
:quad the pair was not mutually inverse — analysis(cfg, synthesis(cfg, alm))
came back as alm / 2π exactly. Analysis now applies
cphi · nlon / phi_inv_scale(cfg) (= cphi under :dft), threaded through the
batch, complex-packed, planned, distributed and adjoint paths.

The direct evaluators applied no φ factor at all: synthesis_point,
synthesis_point_cplx, synthesis_axisym, synthesis_axisym_l, SH_to_lat,
SH_to_lat_cplx, SHqst_to_point, SH_to_grad_point, SHqst_to_lat and the
PencilArray local evaluations each disagreed with the grid they claim to sample
by exactly 2π under :quad. They now carry the same factor synthesis does.

An unset phi_scale (:auto) fell back to grid_type == :gauss ? nlon : nlon/2π,
so a regular grid built through the exported SHTConfig(; …) constructor
disagreed by 2π with the identical grid from create_regular_config. Every
constructor emits :dft, so unset now means :dft.

Order-mixing rotations silently truncated at mmax < lmax. A Wigner-d
rotation through a general β couples Y_l^m to every |m'| ≤ l; components past
mmax had nowhere to go and were dropped without an error — measured at
lmax = 8, 14.8 % of the field energy at mmax = 5 and 24.0 % at
mmax = 3. Such a rotation now raises. β ≡ 0 (diagonal) and β ≡ π
(anti-diagonal) are exempt, so pure Z-rotations still work at any mmax.

The turbo transforms ignored mres. Both walked a bare 0:mmax, so
analysis_turbo populated — and synthesis_turbo consumed — columns an
mres > 1 transform has no storage for; the disagreement with the core pair was
O(1), not roundoff. They also nested @threads :static, illegal inside an outer
threaded region. Both now share the core's cached m ordering and threading
predicate.

Three rotation pullbacks read coefficients the primal had overwritten.
SH_Yrotate, shtns_rotation_apply_cplx and shtns_rotation_apply_real
captured the primal input and read it lazily, so an in-place call
(Rlm === Qlm) or any caller reusing the buffer corrupted the angle gradient.
Each now snapshots at primal time (ChainRules and Zygote both).

rrules declared fewer keywords than their primals. Passing use_rfft or
fft_scratch — even at its default — made ChainRules skip the
analysis/synthesis/sphtor rules entirely and fall through to source tracing.
synthesis_qst/analysis_qst had no rule at all and crashed inside FFTW.

Dead code and API

The SHTNSKIT_CACHE_PENCILFFTS variable and the four *_fft_plan_cache*
controls forwarded to a cache in the parallel extension whose only reader,
_get_or_plan, had no call sites — the "plans" it stored were NamedTuple
placeholders the FFT wrappers ignored, so every knob was a no-op. The dead cache
(and the unreachable helpers around it) is deleted; the controls now address the
cache in src/fftutils.jl that the serial and distributed transforms actually
use, without requiring the extension to be loaded.

DistributedSpectralPlan2D no longer attaches a finalizer: close frees
sub-communicators via MPI_Comm_free, which is collective, while garbage
collection is rank-local and nondeterministic. Cleanup is explicit only.

spatial_view(cfg, A) is exported — the missing bridge in the padding API.
allocate_padded_spatial returns nlat_padded ≥ nlat rows while every transform
requires exactly nlat, so the padded buffer could not be passed to analysis
at all. set_batch_size! is documented as advisory metadata (the batch entry
points take the field count from size(fields, 3)).

Structural SHTConfig fields keep their derived state consistent or refuse
assignment — cfg.lmax = 10 used to leave size(cfg.Nlm) == (7, 7) while the
transforms indexed it as (lmax+1, mmax+1) under @inbounds. The exported
keyword constructor now validates the invariants the create_*_config helpers
have always enforced.

Documentation

The regular grids' exactness threshold is documented with measured numbers:
Fejér and Clenshaw–Curtis with nlat nodes are exact through degree nlat - 1,
and analysis integrates degree 2·lmax, so they need nlat ≥ 2·lmax + 1 where
Gauss–Legendre needs nlat = lmax + 1. Below that nothing warns — 7.2e-2
relative round-trip error at lmax = 8, nlat = 10.

Deliberately not ported

The audit branch also rewrote src/plan.jl to route the planned transforms
through the shared orchestrators (fixing a table-dispatch miss and a doubled
Legendre traversal). That is a performance refactor, and main has since
re-engineered the same file for alias-safety and allocation-free non-canonical
operation. Rather than risk those properties, this PR ports only the
conformance test: planned vs cfg agreement across the tables × rfft ×
Robert-form matrix. It passes on main as-is, confirming there is no correctness
gap — only the unrealised speedup.

Validation

  • 76,848 serial assertions, 0 failures
  • 1,714 parallel-grid assertions
  • MPI at 4 ranks: comprehensive, extended, audit-fixes, and the new
    test_mpi_2d_alignment.jl (wired into CI)
  • MPI at 2 ranks: local correctness, comm cleanup, plan preflight, transpose
    operand preflight, transpose SHT, dealiased transpose, plan allocation

Julia 1.11.1, one thread, no CUDA or ROCm hardware. Pkg.test(), JET, Aqua and
the GPU/parity runners were not re-run. Readiness evidence was refreshed the
way c421d3ed does it: the host-transfer allowlist regenerated by its own
generator (685 → 676 entries, reflecting the deleted dead code and the new
pullback snapshots), the audited tree digest recomputed, and this run recorded in
its own block so the historical readiness figures stay labelled as historical.

🤖 Generated with Claude Code

subhk and others added 2 commits September 13, 2026 06:55
…ad FFT cache

Convention, AD and dead-code fixes found in a full-codebase audit. Every change
is behaviour-preserving under the default `:dft` configuration; the breaking
moves are confined to `:quad` and to hand-built configurations.

phi_scale was only half-implemented. `synthesis` scaled its Fourier bins by
`phi_inv_scale(cfg)` while `analysis` applied a fixed `cfg.cphi`, so under
`:quad` the pair was not mutually inverse — `analysis(synthesis(alm))` came back
as `alm/2π` exactly. Analysis now applies `cphi*nlon/phi_inv_scale(cfg)`, which
is `cphi` under `:dft`, threaded through the batch, complex-packed, planned,
distributed and adjoint paths. The direct evaluators (`synthesis_point`,
`synthesis_axisym`, `SH_to_lat`, `SHqst_to_point`, `SH_to_grad_point`,
`SHqst_to_lat` and the PencilArray locals) applied no φ factor at all and so
disagreed with the grid they sample by 2π; they now carry the same factor. An
unset `phi_scale` used to fall back to a grid-type guess, which made a regular
grid from the exported keyword constructor disagree by 2π with the identical
grid from `create_regular_config`; unset now means `:dft`, as every constructor
emits.

A Wigner-d rotation through a general β couples every |m'| <= l, so a layout
with mmax < lmax silently dropped the components it could not hold — 14.8 % of
the field energy at lmax=8/mmax=5, 24.0 % at mmax=3, with no error. Such a
rotation now raises. The two non-mixing angles (β = 0, π) still work at any
mmax, so pure Z-rotations expressed as ZYZ are unaffected.

The turbo transforms walked a bare `0:mmax` instead of `0:mres:mmax`, making
`analysis_turbo`/`synthesis_turbo` disagree with their core counterparts by O(1)
on any `mres > 1` config, and nested `@threads :static` illegally inside an
outer threaded region. Both now share the core's cached m ordering and threading
predicate.

Three rotation pullbacks (`SH_Yrotate`, `shtns_rotation_apply_cplx`,
`shtns_rotation_apply_real`) read their primal input lazily, so an in-place call
or a reused buffer silently corrupted the angle gradient; each now snapshots at
primal time. The `analysis`/`synthesis`/sphtor rrules declared fewer keywords
than their primals, which made ChainRules skip them entirely once a caller
passed `use_rfft` or `fft_scratch`. `synthesis_qst`/`analysis_qst` had no rrule
at all and crashed inside FFTW under Zygote.

The `SHTNSKIT_CACHE_PENCILFFTS` knob and the four `*_fft_plan_cache*` controls
forwarded to a cache in the parallel extension whose only reader had no call
sites, storing NamedTuple placeholders the FFT wrappers ignored. The dead cache
is deleted and the controls now address the one in `src/fftutils.jl` that the
serial and distributed transforms actually use. `DistributedSpectralPlan2D` no
longer attaches a finalizer, since `MPI_Comm_free` is collective and garbage
collection is rank-local.

Also: structural `SHTConfig` fields keep their derived state consistent or
refuse assignment; the exported keyword constructor validates its invariants;
`spatial_view` is exported as the missing bridge in the padding API; and the
regular grids' `nlat >= 2*lmax + 1` exactness threshold is documented with
measured numbers.

Validation: 76,848 serial assertions; 1,714 parallel-grid assertions; MPI at 4
ranks (comprehensive, extended, audit-fixes, 2d-alignment) and 2 ranks (local
correctness, comm cleanup, plan preflight, transpose operand preflight,
transpose SHT, dealiased transpose, plan allocation). Julia 1.11.1, one thread,
no CUDA or ROCm hardware present; Pkg.test/JET/Aqua and the GPU and parity
runners were not re-run. Readiness evidence refreshed accordingly: the
host-transfer allowlist regenerated (685 -> 676 entries) and the audited tree
digest recomputed, with this run recorded separately from the historical
readiness runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dbj4TtuW11gke9JwSNs7Nz
…same slack

`test_qst_transforms.jl`'s `synthesis_qst_l` ceiling and `test_plan.jl`'s
`cfg`-form `synthesis!` ceiling are calibrated for a single-threaded run. The
shared m-loop orchestrators start `@threads` tasks whenever threads are
available, and each threaded region costs a few hundred bytes to spawn — a
constant independent of problem size, not the field-sized regression these
budgets exist to catch. Both went red at `-t 4` (13392 > 13000 and 2464 > 128);
the `test_plan.jl` one is pre-existing on main.

Uses the same `_thread_alloc_slack()` helper the four other budget files already
carry. The planned `synthesis!` / `synthesis_sphtor!` ceilings are left strict:
those own their scratch and spawn no tasks, so they stay at 0 regardless of
thread count.

Serial suite: 76,848 pass at one thread and at four.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dbj4TtuW11gke9JwSNs7Nz
`_evaluator_phi_scale` returns a `Float64`, so multiplying a `Float32` point or
latitude evaluation by it promoted every result to `Float64` — breaking the
element type these functions promise their caller, and the `Dual` types AD needs
to see through. `test/parity/runtests_cpu.jl` caught it: 64 failures on
`@test all(value -> value isa T, qst_host)` in the local-evaluation parity
suite, all element-type assertions, no numeric drift.

Adds `_evaluator_phi_scale(cfg, ::Type{T})`, which narrows the scale to the
evaluator's own real type, and uses it at every site: `SHqst_to_point`,
`SH_to_grad_point`, `SH_to_lat`, `SH_to_lat_cplx`, `SHqst_to_lat`,
`synthesis_point` and `synthesis_point_cplx`. The axisym pair and the PencilArray
locals already converted explicitly and are unchanged.

Pins the contract with a new testset that asserts the returned element type for
every evaluator across `Float32`/`Float64` and both `phi_scale` modes, so the
serial suite catches this without the parity runner.

test/parity/runtests_cpu.jl now exits 0 (was 2148 passed / 64 failed; stock
origin/main is 2212/2212, so this was a regression introduced by the evaluator
phi_scale change in d9950cf, not a pre-existing failure). Serial suite 76,884
pass at one thread and at four.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dbj4TtuW11gke9JwSNs7Nz
@subhk

subhk commented Sep 13, 2026

Copy link
Copy Markdown
Member Author

Two follow-up commits.

b31a8e10test_qst_transforms.jl's synthesis_qst_l budget and
test_plan.jl's cfg-form synthesis! budget both went red at -t 4
(13392 > 13000 and 2464 > 128). Both now use the _thread_alloc_slack() helper
the four other budget files already carry. The test_plan.jl one is pre-existing
on main. Planned-form ceilings stay strict — those own their scratch and spawn
no tasks, so they remain at 0 regardless of thread count.

5ba07e5e — a real regression this PR introduced, caught by
test/parity/runtests_cpu.jl. _evaluator_phi_scale returns a Float64, so the
new φ factor promoted every Float32 point/latitude evaluation to Float64,
breaking the element type those functions promise (and the Dual types AD needs
to see through). 64 failures, all @test all(value -> value isa T, …) element-type
assertions in the local-evaluation parity suite — no numeric drift. Stock
origin/main is 2212/2212 there, so this was mine, from d9950cf.

Fixed with _evaluator_phi_scale(cfg, ::Type{T}), applied at every site
(SHqst_to_point, SH_to_grad_point, SH_to_lat, SH_to_lat_cplx,
SHqst_to_lat, synthesis_point, synthesis_point_cplx). Added a testset that
asserts the returned element type for every evaluator across Float32/Float64 and
both phi_scale modes, so the serial suite catches this class without the parity
runner.

Validation now

  • serial suite 76,884 / 0 at one thread and at four
  • test/parity/runtests_cpu.jl exits 0, zero failures (SHTns 3.7 fixture
    manifest 1082/1082, local-evaluation parity 2212/2212)
  • parallel grid 1,714; MPI 4-rank and 2-rank suites as before

Readiness evidence refreshed for the new tree, with the CPU parity results
recorded in the audit2_* block.

On the SHTns 3.7 parity failure reported separately

Not a defect in that check and nothing to remove. reference/shtns37/generate.c
and the fixture manifest were added to main in 5016bff7, which is not an
ancestor of audit/2026-09-full-codebase-review — so the file genuinely does not
exist on that branch, and isfile correctly returns false there. The check
passes on this PR. #52 has been closed as superseded.

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.

1 participant