Skip to content

perf(numba): HST curvature matrix phase 2 — mapper×mapper block + MGE operated matrix #507

Description

@Jammy2211

Overview

Successor to #505. #505 took the HST rectangular numba likelihood evaluation from 1.56 s to 0.60 s by
routing the mapper x linear-func block of the curvature matrix F through the batched FFT Convolver.
The post-#505 split is now mapper x mapper 0.277 s (46%), MGE operated mapping matrix ~0.22 s
(37%)
, everything else <= 0.05 s. Phase 2 attacks those two blocks, targeting <= ~0.35 s/eval
with the log-likelihood unchanged to pinned tolerance.

Blocked until #505 merges — phase 2 edits the same files (sparse.py,
inversion_imaging_numba_util.py, the two autolens_profiling breakdown harness scripts, the results
note). Branch from main once #505 is in. Registered in PyAutoMind/planned.md as blocked.

Plan

  • Step 0 — instrument first (checkpoint gates everything after). Split the MGE operated mapping matrix harness row into three timed sub-rows and record the geometry constants into the JSON, so the
    lever is chosen from a measurement rather than an estimate.
  • Step 1 — mapper x mapper, bit-identical restructuring. Write the oracle test the kernel does not
    have today, then hoist the re-gathered (pix, weight) pairs into locals. Estimated 10-20%.
  • Step 2 — mapper x mapper, two-stage reformulation. Per-data-pixel dense source-space accumulator
    followed by contiguous AXPYs, replacing ~2e8 irregular read-modify-writes into a 4.9 MB matrix with
    ~5e7 L1 scatters plus vectorisable dense adds. Estimated 1.7-2.5x on the block (0.277 -> 0.11-0.16 s),
    at rtol=1e-6.
  • Step 3 — MGE operated matrix (gated by the step-0 split). A small bit-identical OverSampler
    divisor cache in PyAutoArray, plus a shared-geometry fast path in PyAutoGalaxy for
    LightProfileLinearObjFuncList — the 60 MGE Gaussians share centre/ell_comps and differ only in
    sigma, so the transform and eccentric-radius grid are recomputed 120x per evaluation. Estimated
    MGE 0.22 -> ~0.10-0.13 s.
  • Step 4 — measure and ship. Paired same-session before/after (alternate B/A/B/A; host variance is
    20-30%), three pins at explicit rtol=1e-6 plus a curvature_matrix array comparison, a Nautilus
    pool run, note appended in autolens_profiling, then ship_library PyAutoArray ->
    ship_library PyAutoGalaxy -> ship_workspace autolens_profiling.

Two findings from the planning read-through shaped this: the MGE half of the cost lives in
PyAutoGalaxy, not PyAutoArray (the Convolver batching from #505 is already applied there); and two
of the four mapper x mapper candidate levers are dead on arrival — upper-triangle symmetry is already
exploited (the sparse preload stores ip1 >= ip0 and the kernel folds A + A^T) and unique-mappings
compression is already what the kernel iterates over. The live lever is the two-stage reformulation.

Detailed implementation plan

Affected Repositories

  • PyAutoArray (primary)
  • PyAutoGalaxy
  • autolens_profiling

Branch Survey

Repository Current Branch Dirty?
./PyAutoArray main clean
./PyAutoGalaxy main clean
./autolens_profiling main clean

worktree_check_conflict reports PyAutoArray and autolens_profiling are both claimed by the in-flight
task numba-hst-curvature-matrix-speedup (#505), and autolens_profiling additionally by
nuts-warm-start-driver-and-a100-probe. Hence: queued in planned.md, not started.

Suggested branch: feature/numba-hst-curvature-matrix-phase2
Worktree: ~/Code/PyAutoLabs-wt/numba-hst-curvature-matrix-phase2/

Implementation Steps

Step 0 — instrument (checkpoint gates everything after)

Files: autolens_profiling/scripts/imaging/likelihood_breakdown/{pixelization_numba,delaunay_numba}.py
(keep STEP_ACCESSORS in sync as #505 did).

  1. Split the MGE operated mapping matrix row into 3 timed sub-rows — 60x image_2d_from(grid),
    60x image_2d_from(blurring_grid), 1x psf.convolved_mapping_matrix_via_real_space_np_from(...)
    with the existing row becoming the residual (same scheme as the F rows; the override is a
    cached_property on the func object so sub-timings do not prime it; the four rows must sum).
  2. Record geometry constants once into the JSON: sparse_operator.lengths.sum()/mean()/max(),
    unique_mappings.pix_lengths.mean()/max(), data_to_pix_unique.shape, mapper.params,
    n_image_pixels, PSF shape.
  3. Do not split mapper x mapper further (single mapper -> one kernel call; fold loops are
    n_source^2, noise).
  4. Run hst/euclid rectangular + hst Delaunay-1250 (OMP_NUM_THREADS=1 AUTOARRAY_NUMBA_OPERATED_MEMO=0, n_repeats 10); post the split to this issue.
    Checkpoint: confirms (a) profile-eval vs convolution share of MGE, (b) the mapper x mapper op count.

Step 1 — mapper x mapper, bit-identical restructuring (own commit)

Kernel: curvature_matrix_via_sparse_operator_from (inversion_imaging_numba_util.py ~:496).

  1. Write the oracle first — it has no direct unit test today. New
    test__curvature_matrix_via_sparse_operator_from__matches_dense_psf_precision_operator in
    test_autoarray/inversion/inversion/imaging/test_inversion_imaging_util.py: dense M^T W M vs the
    kernel, parametrised over KERNELS_ODD (asymmetric, non-square). Plus a symmetry / halved-diagonal
    test. Keep the current kernel as curvature_matrix_via_sparse_operator_reference_from.
  2. Hoist data_1's <= u1 (pix, weight) pairs into locals once per stored pair (today re-gathered u0x
    from wide-stride 2-D arrays) and take curvature_matrix[pix_0] as a row view. Estimated 10-20%.
    Verify np.array_equal on inversion.curvature_matrix before/after (euclid + hst).

Step 2 — mapper x mapper, two-stage reformulation (own commit, rtol 1e-6)

  1. For each data_0: accumulate the dense a[p] = sum_{data_1} W(data_0,data_1) * sum_b w1_b delta(pix1_b, p) into an L1-resident n_source vector, then A[pix_0,:] += w0 * a for each of
    data_0's u0 mappings; keep the A + A^T fold and the halved-diagonal contract.
  2. Valid while n_source <~ P * u1 (~3400 at HST; 784/1250 well inside). Keep the reference loop behind
    a runtime branch on pix_pixels; measure the crossover, and if it is dataset-dependent expose it
    as an explicit setting rather than a silent heuristic.
  3. Tests: ..._two_stage_matches_reference_kernel parametrised over KERNELS_ODD x pix_pixels on both
    sides of the threshold; inversion-level test__curvature_matrix_mapper_diag__matches_reference_kernel
    in test_curvature_matrix_func_list_blocks.py; the multi-mapper twin
    curvature_matrix_off_diags_via_sparse_operator_from gets its own oracle if touched. Every rtol test
    gets a recorded control run (a deliberately wrong variant must fail).
  4. prange: not planned. Zero gain under Nautilus one-process-per-core, and it needs per-thread
    n_source^2 buffers plus a lazily cached prefix-offset array on SparseLinAlgImagingNumba (pickled to
    workers — no new ctor args). Only if step 2 misses target, default off, both arms measured.
  5. Rejected: pruning small psf_precision_operator entries (moves the likelihood; fails the pin).

Step 3 — MGE operated matrix (gated by the step-0 split)

  1. PyAutoArray, small, bit-identical: OverSampler.binned_array_2d_from
    (autoarray/operators/over_sampling/over_sampler.py ~:202) recomputes the model-independent
    np.bincount(segment_ids) divisor on every one of ~120 calls/eval. Cache it as an immutable
    cached_property (today the code mutates counts in place — the cached array must not be mutated).
    Wider blast radius (every numpy light-profile eval) -> np.array_equal test across repeated calls
    plus the harness Blurred image row as witness.
  2. PyAutoGalaxy: shared-geometry fast path in LightProfileLinearObjFuncList
    (autogalaxy/profiles/light/linear/abstract.py, mapping_matrix ~:291,
    operated_mapping_matrix_override ~:320): when all profiles are the same class with identical
    centre/ell_comps, compute the transform and eccentric radii once per grid and evaluate the 60
    Gaussians vectorised over sigma; binning stays a loop initially (binned_array_2d_from is 1-D).
    Fall back to the current loop otherwise. Tests: ..._shared_geometry_matches_per_profile_loop
    (assert bit-identical first; relax to rtol 1e-6 only with a recorded reason) and
    ..._mixed_geometry_falls_back. Estimated MGE 0.22 -> ~0.10-0.13 s.
  3. Not pursued: reusing ConvolverState.fft_kernel on the numpy path (scipy already avoids the 3-D FFT;
    single-digit %; a 10-line microbench only), and a narrower memo keyed on centre+ell_comps (weaker
    than the hoist).

Step 4 — measure + ship

  1. Paired same-session before/after (host variance 20-30%; alternate B/A/B/A), three paired cells + RTU
    once for currency (unpaired, GPU-only by decision). Discard the first post-rebuild run (numba
    cache: true; documented cold-cache psf_weighted_data_from hazard).
  2. Pins at explicit rtol=1e-6 (the harness default is 1e-4) plus a curvature_matrix array comparison.
  3. Pool run: cpu_fast_modeling.py with PYAUTO_TEST_MODE=1, PYAUTO_SMALL_DATASETS unset, cores =
    host, plus a linear MGE bulge (the smoke profile runs no pool); compare the parallel speed-up ratio.
  4. Note section appended to autolens_profiling/results/notes/numba_curvature_matrix_f_split.md.
  5. ship_library PyAutoArray -> ship_library PyAutoGalaxy (if touched) -> ship_workspace
    autolens_profiling. Merge is human.

Key Files

  • PyAutoArray: autoarray/inversion/inversion/imaging_numba/inversion_imaging_numba_util.py,
    autoarray/inversion/inversion/imaging_numba/sparse.py,
    autoarray/operators/over_sampling/over_sampler.py,
    test_autoarray/inversion/inversion/imaging/test_inversion_imaging_util.py,
    test_autoarray/inversion/inversion/test_curvature_matrix_func_list_blocks.py
  • PyAutoGalaxy: autogalaxy/profiles/light/linear/abstract.py,
    autogalaxy/profiles/light/standard/gaussian.py, autogalaxy/profiles/geometry_profiles.py,
    new tests under test_autogalaxy/profiles/light/linear/
  • autolens_profiling: scripts/imaging/likelihood_breakdown/pixelization_numba.py,
    scripts/imaging/likelihood_breakdown/delaunay_numba.py, _profile_cli.py,
    results/notes/numba_curvature_matrix_f_split.md

Verification

pytest test_autoarray (baseline 1296) and pytest test_autogalaxy green; the oracle test passes on the
unmodified kernel before any change; control runs recorded for every rtol test; bit-identical
curvature_matrix for step 1 and the OverSampler change; three pins at rtol 1e-6 (hst rectangular
27661.910133664103, hst-rtu 27180.704715696862, hst-delaunay 29090.52721044813); smoke
cpu_fast_modeling.py; paired before/after table + pool ratio in the note and on this issue.

Marked undetermined (step 0 measures these, never guessed)

The MGE internal split; the actual psf_precision_lengths.sum() / pix_lengths.mean(); whether LLVM
already hoists the scalar product; the two-stage crossover n_source; the numba threading layer on WSL
(only if prange ever runs).

Original Prompt

Click to expand starting prompt

Numba CPU likelihood at HST resolution, phase 2: the mapper×mapper block and the MGE operated matrix

Type: feature
Epic: none (successor to numba_cpu_hst_curvature_matrix_speedup, PyAutoArray#505)
Target: autoarray
Repos:

  • @PyAutoArray
  • @PyAutoGalaxy
  • @autolens_profiling
    Themes:
  • numba-cpu
  • pixelization
  • profiling
    Difficulty: large
    Autonomy: supervised
    Priority: high
    Status: draft
    Filed: 2026-08-28

Original request

yeah do that phase 2

(in reply to: "What's left for a phase 2, if you want one: mapper×mapper (0.28 s) and the MGE
operated matrix (0.22 s) now lead at HST.")

Context

PyAutoArray#505 (branch feature/numba-hst-curvature-matrix-speedup, shipping 2026-08-28) routed
the mapper × linear-func block of the curvature matrix F through the batched FFT Convolver
(Convolver.reversed_kernel), taking F 1.20 → 0.36 s and the HST rectangular numba evaluation
1.56 → 0.60 s (OMP_NUM_THREADS=1, AUTOARRAY_NUMBA_OPERATED_MEMO=0, paired same-session
measurements; note in autolens_profiling/results/notes/numba_curvature_matrix_f_split.md).

Post-#505 HST rectangular split (0.60 s/eval):

step s share
F: mapper×mapper [numba sparse-op / w-tilde contraction] 0.277 46%
MGE operated mapping matrix (60 funcs, per-eval convolution of varying profiles) ~0.22 ~37%
F: mapper×linear-func [FFT conv + scatter] 0.054 9%
everything else ≤ 0.02 each

Delaunay-1250: mapper×mapper 0.123 s of 0.76 s; F is no longer dominant there — the inversion build
(~0.5 s) is, which is out of scope here.

Plan findings (2026-08-28)

Two read-through findings changed the task's shape at planning time. First, the MGE half of the
cost lives in PyAutoGalaxy, not PyAutoArray
: the #505 Convolver batching is already applied in
LightProfileLinearObjFuncList.operated_mapping_matrix_override (the 60 Gaussians are stacked into
one convolution) and scipy already skips the length-60 axis, so the bulk of the ~0.22 s is the 120
per-profile image evaluations, all of which recompute an identical transform and eccentric-radius
grid because the MGE basis shares centre/ell_comps and varies only in sigma — hence
@PyAutoGalaxy added to Repos above. Second, two of the four mapper×mapper candidate levers are
dead on arrival
: upper-triangle symmetry is already exploited (the sparse preload stores
ip1 >= ip0 and the kernel folds A + Aᵀ) and unique-mappings compression is already what the
kernel iterates over. The live lever is a two-stage reformulation — a per-data-pixel dense
source-space accumulator followed by contiguous AXPYs.

Goal

Take the HST rectangular numba evaluation from ~0.60 s to ≤ ~0.35 s with the log-likelihood
unchanged to pinned tolerance (pins: hst rectangular 27661.910133664103, hst-rtu
27180.704715696862, hst-delaunay 29090.52721044813; rtol 1e-6 where summation order changes,
bit-identical otherwise).

  1. Decompose first, as in perf: numba CPU curvature matrix F at HST resolution (phase 1: split + FFT mapper×func block) #505. Instrument the mapper×mapper kernel
    (inversion_imaging_numba_util.py, sparse-op diag + off-diag kernels) and the MGE operated
    mapping matrix step in the autolens_profiling breakdown harness; record the split at hst +
    euclid rectangular and hst Delaunay-1250. Checkpoint: the split picks the lever.
  2. mapper×mapper candidates, cheapest first: unique-mappings compression (is the inner loop over
    data pixels × PSF footprint × source pixels where a per-unique-mapping formulation is smaller);
    upper-triangle-only + mirror; whether the preloaded PSF-precision products are fully exploited
    (nothing mapper-independent recomputed per evaluation); prange measured both at
    OMP_NUM_THREADS=1 and under the Nautilus pool.
  3. MGE operated matrix candidates: determine FFT- vs scatter-bound; batch the varying profiles
    through the same Convolver path as perf: numba CPU curvature matrix F at HST resolution (phase 1: split + FFT mapper×func block) #505 if the per-func convolution is the cost; check whether
    any profile subset is invariant across evaluations for a given model (memo, already
    AUTOARRAY_NUMBA_OPERATED_MEMO).
  4. Ship behind parity tests; test_autoarray green; smoke
    autolens_workspace/scripts/imaging/features/pixelization/cpu_fast_modeling.py; paired
    before/after on the four breakdown cells + one Nautilus pool run; note in autolens_profiling.

Out of scope: the JAX path; RTU / kernel-CDF meshes (GPU-only by decision); the Delaunay inversion
build; the NNLS solve.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions