Performance: reference-frame latency and thread scaling¶
This note pins down where delta spends its time on the reference survey frame and
where the plausible optimisations lie. It backs SPEC §5 / §13.4 / §13.5: a
survey-cadence latency target on an ~8000×6000 frame, scaling with thread count, and
lower runtime than HOTPANTS.
Running it¶
# Reference 8000×6000 frame, full per-stage breakdown (uses all cores):
uv run python -m benchmarks.bench_subtract
# Thread-scaling sweep (one fresh process per point — OpenMP latches the thread
# count at the first parallel region, so the count cannot be changed in-process):
uv run python -m benchmarks.bench_subtract --threads 1,2,4,8,16
# Smaller frame for a quick check; --record appends a SHA-tagged line to
# benchmarks/results/history.jsonl for tracking regressions across commits:
uv run python -m benchmarks.bench_subtract --size 4096 --record
Per-stage timings are read back out of the pipeline's own log_timing
instrumentation (a temporary loguru sink), so the breakdown stays in lock-step with
the stages the pipeline actually reports.
Measurement hygiene — use --reps (warm min-of-N)¶
A single warm sample is not trustworthy on this host. First-touch/page-fault cost and thermal throttling drift any one run by 20-40%, and back-to-back A/B comparisons are worse: the second variant runs on a hotter chip, so whichever you measure second looks slower. This is not hypothetical — an early "the GLS solve is nearly serial (1.04× over 14 threads)" finding was pure artifact of cold, single-sample runs; warm min-of-N showed the same code already scaling ~1.8×.
The robust estimator is warm min-of-N: warm up once, run N timed iterations,
take the minimum per stage (the least-contended sample — the floor a regression
has to beat). Pass --reps N (≥5 for A/B work). For thread scaling, drive
OMP_NUM_THREADS across separate processes (OpenMP latches the count at the first
parallel region) and let the per-process min absorb the thermal drift::
OMP_NUM_THREADS=14 python -m benchmarks.bench_subtract --size 4096 --reps 7
Reference numbers¶
The headline is host-specific (treat the shape of the breakdown, not the absolute
seconds, as the durable signal). Captured on a 14-core machine, 8000×6000 frame
(48 Mpix), 1200 stars, decorrelation + score on, warm min-of-7 (--reps 7), with
DELTA_TIMING=1 for the indented C++ sub-stage splits:
| stage | time (s) | share |
|---|---|---|
| stamp selection | 0.39 | 7.6% |
| kernel solve | 1.17 | 22.7% |
↳ stamp B_n convolve |
0.09 | 1.7% |
| ↳ GLS solve | 0.55 | 10.7% |
| full-frame subtraction | 1.49 | 28.9% |
| ↳ spatial fields | 0.00 | 0.0% |
| ↳ sanitise reference | 0.10 | 1.9% |
| ↳ model convolve | 0.60 | 11.6% |
| ↳ variance propagation | 0.21 | 4.0% |
| noise decorrelation | 1.30 | 25.3% |
| matched-filter score | 0.56 | 10.8% |
| orchestration | 0.25 | 4.8% |
| wall | 5.15 | 100% |
(Per-stage figures are each an independent warm min, so they need not sum to the min
wall — the floor each stage has to beat is what a regression is measured against. The
sub-stage splits also do not fully account for their parent: kernel solve carries
~0.5 s of Python-side stamp prep + CV orchestration outside the two C++ timers, and
full-frame subtraction carries mask growth + difference assembly.)
This is post the full optimisation arc — spatial-field coarse grid (#16), GLS solve (#17), variance propagation (#18), convolution-path cache-blocking (#19) — and the later round folded in on this branch. Treat the shape, not the absolute seconds, as durable (an earlier capture of the pre-#17/#18 code on this class of host read ~21 s; the ratios are the signal). The later round, all bit-identical or validated within tolerance:
spatial fields→ ~0 andmodel convolve2.26→0.60 s: the coefficient fields are no longer materialised full-frame — they are interpolated tile-local inside the model convolve, and the separable x-pass is streamed per tile rather than staged to DRAM (issue #32). The separatespatial fieldsstage is now effectively free.noise decorrelation1.41→1.30 s: per-cell kernel-power cache (one kernel FFT per coarse cell) + subsampled block-noise median, after the overlap-add blend was de-serialised (#28).sanitise referencefused into the model-convolve gather (the full-frame median- filled copy — a 512 MB round-trip — is gone; only the fill scalar is computed up front).matched-filter score0.87→0.56 s: the dense PSF correlation reordered to output-row-outer so the accumulator stays L1-resident (it was L1-bandwidth bound, not tap bound — a separable PSF was tried and ran slower).- orchestration trimmed: the redundant full-frame difference copy/
astyperemoved and the rms log made lazy (the silent-by-default library no longer computes a 48 Mpixnp.stdfor a message no sink emits).
Final pre-deployment pass (2026-07)¶
A further sweep, A/B'd on a 4-core host (same commit pair, same box, warm min-of-5, 8000×6000): wall 8.35 → 6.29 s (1.33×). Stage deltas:
| stage | before (s) | after (s) | delta |
|---|---|---|---|
| stamp selection | 0.33 | 0.23 | −30% |
| kernel solve | 1.67 | 0.90 | −46% |
| full-frame subtraction | 2.74 | 2.28 | −17% |
| noise decorrelation | 1.56 | 1.25 | −20% |
| matched-filter score | 1.25 | 0.83 | −33% |
What landed (all bit-identical — verified by output-hash probes — except the effective-dof identity, which is exact in real arithmetic and shifts only roundoff):
- Zero-copy input views (
ImageViewinimage.hpp):subtract,fit_kernel,decorrelate, andmatched_filterread the caller's NumPy buffers directly instead of copying every full-frame layer into an owningImageF(~190 MB/layer); the one-pass range constructor covers the remaining owning marshals (write_fitsetc.). This is most of the score / decorrelation stage deltas — their C++ kernels were already tight. - Mask growth fused + threaded (
subtract.cpp): five serial full-frame sweeps + two dilations became one threaded flag pass + one combined dilation (OR-dilation is per-bit independent), with the dilated footprint folded into the existing edge/non-finite pass (~2× on 4 threads, more on wider hosts). - IRLS reweight threaded and the thin-plate design cached across IRLS
passes (
fit.cpp; the design was rebuilt inside the solver every pass and again for residuals — the saving scales as O(N·k²) with knot count). - Detect peak scan band-parallelised with a deterministic band-order merge.
- Effective-dof trace identity in
solve_at:tr(A⁻¹M) = P − λ‖L⁻¹F‖²_FwithP0 = FFᵀ(System::pen_half) — one triangular solve over rank(P0) columns instead of the fullA⁻¹Msolve, ~1.6× per factorisation across every λ-selection path. - Assorted: convolve row-zeroing deduplicated,
basis_convolvekernel sampling hoisted, merge vectors reserved, per-fold CV training matrices hoisted out of the (λ, fold) grid.
Two negative results, recorded so they are not re-attempted: Demmler-Reinsch
λ-grid amortisation is numerically unsound here (with a realistic
ill-conditioned thin-plate M, the spectral GCV curve misranks λ — up to
~400× curve error on a P=704 probe; see the NOTE in solve.cpp), and
nanobind LTO measured ±1.5% — indistinguishable from run noise.
The build now defaults to -march=x86-64-v3 (AVX2 + FMA; Haswell 2013+ /
Excavator 2015+, skipped automatically on non-x86 targets). On the same 4-core
host this took the 8000×6000 wall from 6.29 → 4.26 s (model convolve and
score roughly halved — these loops were written to vectorise and the SSE2
baseline was the limiter), i.e. 1.96× cumulative over the pre-pass code.
DELTA_X86_64_V3=OFF restores the SSE2 baseline for pre-2013 CPUs;
DELTA_NATIVE=ON still supersedes both for host-tuned builds (AVX-512 etc.).
Unlike the rest of the pass this shifts float rounding (~1 ulp, FMA
contraction); the full suite passes unchanged. The decorrelate output pair is
also now adopted from the overlap-add accumulators (no fresh full-frame
allocation), and catalog is attributed as its own benchmark stage instead of
landing in "other".
Two findings fall straight out of the splits:
noise decorrelationis now the single tallest stage (~25%), ahead ofmodel convolve(~12%),matched-filter score(~11%), andGLS solve(~11%) — which are themselves within ~1.1× of each other. The FFT is genuine (the ZOGY filter is non-compact in Fourier space) so it stays; further decorrelation wins need approximation, and the cheap structural levers across the other stages are largely spent.variance propagation(~4%) andspatial fields(~0%) have collapsed from their former top-two status.B_nprecompute is negligible (~1.7%) because the fit convolves only the stamp windows, not the whole frame — so the kernel solve is essentially all solve (the λ-grid × CV-fold factorisations), not convolution.
The balance shifts with frame size: the GLS solve is a fixed cost (set by stamp
count and the λ-grid × CV-fold solve count, independent of frame area), so on small
frames it dominates (~62% at 2048²), while on the reference frame the
area-proportional full-frame subtraction dominates at ~42%.
Thread scaling (4096², warm min-of-5)¶
Same 14-core host, 4096×4096 (16.8 Mpix), 419 stars, warm min-of-5 (--reps 5):
| threads | wall (s) | speedup | efficiency |
|---|---|---|---|
| 1 | 4.74 | 1.00x | 100% |
| 2 | 3.14 | 1.51x | 76% |
| 4 | 2.67 | 1.78x | 44% |
| 8 | 2.23 | 2.13x | 27% |
| 14 | 2.43 | 1.95x | 14% |
Scaling is far from ideal: it climbs to ~2.1× by 8 threads, then regresses at 14 as the extra cores contend for the bus (and oversubscribe alongside the Eigen thread team). The cap is memory bandwidth, not a serial section: at this frame size the full-frame convolution/subtract passes (SPEC §5: threaded over rows/tiles purely for parallelism) are bandwidth-bound, so beyond ~4–8 threads extra cores contend for the same bus rather than adding throughput. The headline lever is therefore per-core efficiency (SIMD/cache), not more threads — the cache-blocking and tile-local fusion across the convolution/score paths (#19, #32) cut the single-thread baseline hard while leaving the scaling shape bandwidth-limited.
Note this is a wall-time ceiling on the area-proportional passes, not evidence
that any one stage is serial. The GLS solve in particular — long mis-read as serial
from cold single-sample runs — scales ~1.8× warm via its threaded fold M-build (see
the GLS section), the same bandwidth-limited ceiling as the rest. Measure stage
scaling with --reps (warm min-of-N); a cold sample will not show it. See the
per-stage gaps below.
Sub-stage instrumentation (DELTA_TIMING)¶
The five Python-orchestrated stages are timed by the pipeline's loguru log_timing.
The finer C++ splits above come from opt-in RAII timers
(include/delta/timing.hpp): set DELTA_TIMING=1 and they are accumulated per label,
returned through the fit_kernel / subtract / decorrelate result dicts under
"timing" (for matched_filter, which returns a bare array, the pipeline drains them
via _core.drain_timing()), and logged by the pipeline in the same
"<label> done in <s>s" form the benchmark captures. When DELTA_TIMING is unset the timers compile down to one cached bool
check, so production runs are unaffected. To split a stage further, add a
DELTA_TIME("…") scope in the relevant src/*.cpp and a label to SUBSTAGES in
bench_subtract.py.
Where plausible improvements could lie¶
Ordered by the share each lever had when first identified (completed ones are kept, marked DONE, for the history). Each big lever has a tracking GitHub issue (search the repo issues for "perf:").
subtract: spatial fields — ~0%, was ~27% (src/subtract.cpp, evaluate_fields) — DONE (issues #16, #32)¶
Was the single biggest lever, and a surprise — coefficient-field evaluation, not
convolution: evaluate_fields rebuilt the thin-plate-spline design matrix
spatial.design(points) for every frame row (~6000× over identical structure), an
h·w·k count of log() evaluations. First fixed (#16) by coarse-grid evaluation +
bilinear interpolation: the fields aₙ(x,y) vary on the knot length-scale (≫ a
pixel), so they are evaluated exactly on a coarse lattice and bilinearly interpolated to
full resolution (stride chosen adaptively from ThinPlateBasis::min_knot_spacing(), ~16
samples per knot interval, capped at 64 px, keeping the relative error well below 1e-3;
small frames fall back to the exact per-pixel path, bit-for-bit). That cut it 6.21→2.51 s.
The remaining materialisation cost was then eliminated (#32) by fusing the field
interpolation into the model convolve: the coarse lattice (CoarseFields) is
interpolated tile-local inside the convolution loop (point-sampled in variance prop), so
the full-resolution aₙ/background are never written to DRAM. The separate stage is now
effectively free (~0 s); its former cost moved into — and is hidden by — the model
convolve, which dropped overall (below).
kernel solve (GLS) — ~11%, was ~24% (and dominant on small frames) (src/solve.cpp, src/fit.cpp) — DONE (issue #17)¶
~All solve (B_n precompute is ~1.0%). Builds M = XᵀWX (threaded symmetric
rankUpdate over row chunks) and solves it across the λ-grid × CV-fold grid:
np.logspace(-6, 6, 25) × cv_folds=5 ⇒ up to ~125 factorise/solve passes of the
normal equations. Two exact levers cut it 5.26→3.62 s (−31%):
- Cholesky (
LLT) instead of the pivotedLDLTfor the per-(λ, fold) factorisation of the SPD penalised normal matrixA = M_train + λP₀(with anLDLTfallback if a fold's system loses positive-definiteness at tiny λ). This was the larger win — on the real per-stamp normal matrices Eigen'sLDLTis far slower than itsLLT(≈ −44% on the solve), though on random matrices the two are within ~6%, so it only showed up end-to-end. - Coarse-to-fine λ search in the CV path: the held-out CV error is unimodal in log-λ, so a stride-2 coarse scan plus a ±2 bracket refinement around its minimum evaluates 15 of 25 grid points and selects the identical λ as the full sweep for any unimodal curve. The reduced-χ²/effective-dof diagnostics come from the exact final solve, so they are untouched.
Two further exact refinements (this branch) trim it again, ~5-8% on solve-min each
measured with --reps:
- Row-chunked CV fold M-build. Building each fold's
M_g = Xs_gᵀ Xs_gwas threaded one-fold-per-thread, capping parallelism atcv_folds(=5) regardless of core count — and the build (O(N·P²)) is the solve's dominant cost. It now mirrors the full-dataassemble(): the fold loop is serial on the outside but each fold's symmetricrankUpdateis split over 2048-row chunks across all threads (per-thread gather buffers hoisted out of the chunk loop — allocating them per chunk regressed the 1-thread path). This is what lifts the warm solve scaling to ~1.8× (below); the earlier "solve is serial" read was the cold-sample artifact, not the M-build. - λ-search warm start across IRLS passes. The σ-clip loop re-runs the whole CV solve each pass, but a pass only clips a handful of stamps off a curve the previous pass already located — the optimum barely moves. Passes after the first now seed a ±1 bracket at the previous pass's CV-selected index and descend to the interior minimum (a handful of factorisations) instead of the cold coarse-to-fine sweep. The CV curve is unimodal in log-λ, so the descent lands on the identical λ (verified: same selected λ and difference image to the threaded-reduction FP-noise floor, ~1e-5, which two cold runs already differ by). First pass stays cold-start. Smaller than hoped because the M-build, not the λ-grid factorisations (O(P³), small P), is the solve's floor — see below.
Where the solve's floor now is. With both above, the per-fold M-build dominates: it
runs once per IRLS pass regardless of how many λ points are factorised, so cutting λ
work only trims the tail. The next real lever is avoiding the rebuild on passes that
clip few pixels — a rank downdate of the clipped rows out of the existing M_g rather
than a full re-accumulate — which is materially more complex than these two changes.
The hypothesised factorisation reuse across the λ-grid (a single generalised
eigendecomposition of the (M, P₀) pencil, then O(P)-per-λ diagnostics) was implemented
and measured but does not pay off at the relevant size: one symmetric eigensolve at
P = (nc+1)k ≈ 812 costs ≈ 25–50 Cholesky factorisations, so it is a net loss for both
the 25-point GCV grid and the 5-fold CV path. Left out.
subtract: variance propagation — ~3%, was ~21% (src/subtract.cpp) — DONE (issue #18)¶
The block-effective squared-kernel convolution (freeze K=Σaₙφₙ per tile, square it,
convolve Var(R)) was a near-full-frame second convolution. Cut 4.46→0.47 s (~9.6×)
by the flat-Var(R) shortcut: where the reference variance is near-constant over the
kernel footprint — the sky-dominated common case — K² ⊗ Var(R) ≈ Var(R)·Σᵤ K²(u), i.e.
a per-pixel scale by the kernel sum-of-squares Q, with no ks² convolution at all. Each
tile gathers its Var(R) window (tile + kernel-radius halo) and takes the shortcut only
when the window's relative spread (max−min)/max is below 1e-3, which bounds the
shortcut's relative error by the same; otherwise it falls through to the exact
convolution. Because the haloed window is the exact footprint the convolution reads, a
tile near a bright source (a Poisson variance spike) reads non-flat and stays exact —
verified to ~1e-6 relative against a direct K² convolution on a flat-sky-plus-bump map.
The ~9.6× here is the constant-Var best case (every tile flat); a frame with structure
near sources sees less, but the sky tiles that dominate a survey frame still get it.
- Remaining lever: adaptive tile size where the kernel itself is near-constant, and
fusing with the model-convolve pass (same neighbourhoods, read once).
subtract: model convolve — ~12%, was ~14% (src/subtract.cpp, src/convolve.cpp) — DONE (issues #19, #32)¶
The separable model convolution, 2D-tiled and fully fused (#19): the per-component
loop runs inside a 96×96 tile loop, so a tile-local difference accumulator plus the
shared x-passes for the tile's rows stay in L2 while every component is swept. The
earlier version looped components on the outside with a full-frame y-pass each,
read-modify-writing the whole diff nc(=28)× and re-reading each tx[nx] from DRAM
per component — memory-bandwidth bound. #32 then removed the two remaining DRAM
round-trips: the separable x-pass is computed per tile into a thread-local buffer
(never staged full-frame, ~1.3 GB saved) and the coefficient fields are interpolated
tile-local (the spatial fields ~5.4 GB materialisation, above). The reference
sanitisation is also fused in (its own stage below): each tile gathers its haloed
reference window with the median fill applied in place, so the convolution reads it with
no per-tap bounds test and the full-frame sanitised copy never materialises. Per-pixel
accumulation order is unchanged throughout, so the result stays bit-identical. y-pass
micro-opts (register-blocking, tap-reorder) are duds — compute/L1-bound at a local
optimum. Remaining lever: fusing with the variance pass (same neighbourhoods).
subtract: sanitise reference — ~2% (src/subtract.cpp) — DONE (fused)¶
Masked / non-finite reference pixels are replaced by the median background before the convolution (a zero fill would ring the model at every mask boundary; SPEC §3.6). This used to materialise a full-frame sanitised copy (256 MB write + 256 MB read) whose only consumer was the model convolve. Now only the median fill scalar is computed up front (stride-7 sample → bit-identical fill) and the substitution is fused into the model- convolve tile gather. What remains in the stage is just the serial median sample; coarsening its stride would speed it further but changes the fill value, so it is kept at 7 to preserve bit-identity.
noise decorrelation — ~25% (now the tallest stage) (src/noise.cpp)¶
Apodised FFT blocks (vendored PocketFFT, one workspace per thread, threaded over blocks). The FFT is
genuine (the ZOGY decorrelation filter is non-compact in Fourier space), so it stays.
Wins so far: the overlap-add blend was de-serialised via a 9-colour lock-free scheme
and the Hann window precomputed (#28); then a per-cell kernel-power cache — |K̂|²
and ΣK² depend only on the matching kernel, which varies on the knot scale ≫ the block
stride, so they are cached one kernel FFT per coarse cell (auto-sized from knot spacing)
and reused across the cell's blocks, dropping one of the three FFTs per block — plus a
subsampled block-noise median (block_variance ran an nth_element over 65 k floats
twice per block; now ~4 k samples). Validated within tolerance against the exact
per-block path (kernel_cell_blocks=1). As the now-tallest stage with the FFT
irreducible, further wins need a coarser approximation; it is optional
(decorrelate=False).
matched-filter score — ~11% (src/noise.cpp) — DONE (reorder)¶
A dense PSF correlation of the (decorrelated) difference. Each tile gathers its haloed
input window once into a contiguous L1/L2-hot buffer (#19). The hot loop is now
output-row-outer (oy, ly, lx, ox): a single output row's accumulator stays
L1/register-resident while all ks² taps reduce into it, and each tap reads only the
ks window rows directly above the row. The earlier tap-outer order swept the whole tile
per tap, so the accumulator and haloed window together overran L1 and the window
thrashed — the stage was L1-bandwidth bound, not tap bound (a separable low-rank PSF
was tried and ran slower for exactly this reason, and is not worth the approximation).
Same per-pixel (ly,lx) sum order → bit-identical. Also optional (score=False).
stamp selection — ~8% (src/detect.cpp)¶
Smallest share; a full-frame threshold/FWHM scan. Low priority on wall time, but its absolute time was observed to rise slightly with thread count in small-frame sweeps, which would make it a relative serial bottleneck once the convolutions are sped up — worth confirming it parallelises (or stays cheap) before it becomes the tall pole.
Recording for regression tracking¶
--record appends one JSON object per run to benchmarks/results/history.jsonl
(git SHA, host, platform, per-stage timings). Diffing the latest two lines after a
change makes a regression — or a win — visible without re-deriving the baseline.