Skip to content

perf: cut positive-only solve active-set iterations — warm-start memo + diagnostics (numba CPU likelihood, phase 3a) #498

Description

@Jammy2211

Overview

Phase 3a of the numba CPU sparse-operator likelihood speed restoration (epic numba-cpu-likelihood). On the Delaunay-1250 + Hilbert AdaptImage campaign fiducial (apply_sparse_operator_cpu(), use_jax=False) the positive-only reconstruction solve is ~72% of a euclid evaluation and is iteration-bound (autolens_profiling#151 comments 5-6): the production warm start (P_initial = np.linalg.solve(curvature_reg, data_vector) > 0, inversion_util.py:378) gets ~1411/1560 entries right, and fnnls_cholesky adds one index per outer iteration and drops violators one at a time, so ~150 wrong entries ⇒ ~150 outer + ~100 inner iterations. PR #453/#463 already removed the per-iteration copy overhead (record complete/2026/08/numba-fnnls-inplace-cholesky-buffer.md); the iteration COUNT is untouched. The "restore deleted numba fnnls" idea is retired — the solver was never missing.

This task instruments the solver, measures on a realistic instance sequence which warm start beats the dense sign pattern, then ships a cross-evaluation warm start behind a settings knob + env kill-switch. Solution unchanged (unique NNLS optimum), pinned log-likelihoods must hold at rtol 1e-6. Phase 3b (multi-index / batched active-set moves, with a single-index fallback — note block principal pivoting already lost against the JAX PDIP solver, autolens_profiling/results/notes/nnls_solver_ledger.md) is a separate prompt driven by 3a's diagnostic.

Plan

  • Expose solver diagnostics from fnnls_cholesky (final passive set, outer/inner iteration counts, warm-start error count) via an optional stats dict — default return unchanged.
  • Perturbed-instance diagnostic on the euclid/hst Delaunay-1250 fiducial: per solve record iterations, |P_final ⊕ P_dense-sign|, |P_final(t) ⊕ P_final(t−1)| over a random-walk sequence AND an i.i.d.-draw sequence (nested-sampling order), solve seconds. Run with AUTOARRAY_NUMBA_OPERATED_MEMO=0.
  • Cross-evaluation warm start: module-level FIFO memo (template: imaging_numba/sparse.py:20-52), keyed on problem size + mesh fingerprint, Settings knob + AUTOARRAY_NNLS_WARM_START=0 kill-switch, dense-sign fallback on miss. Fork-based pools give each worker its own memo automatically.
  • Cheap exact wins alongside: seed the Cholesky buffer from the warm-start factorisation instead of a second from-scratch slg.cholesky; skip the separate dense np.linalg.solve when a warm passive set is available.
  • Tests + measurement; gate for on-by-default = median iteration reduction ≥2× on the random-walk sequence with zero pin drift, else ship default-off and let the numbers drive 3b.
  • autolens_profiling leg (diagnostic script + results + notes) ships after harvest-0827-gate-b-pt2 (autolens_profiling#183) merges — it holds the repo claim; files are disjoint.
Detailed implementation plan

Work Classification

Library (PyAutoArray) first; workspace leg (autolens_profiling) follows.

Affected Repositories

  • PyAutoArray (primary)
  • autolens_profiling (measurement/results leg, queued behind autolens_profiling#183)

Branch Survey

Repository Current Branch Dirty?
./PyAutoArray main clean
./autolens_profiling main clean — CLAIMED by harvest-0827-gate-b-pt2 (awaiting-merge, touches only scripts/misc/searches + inference results)

Suggested branch: feature/numba-cpu-nnls-iteration-reduction
Worktree root: ~/Code/PyAutoLabs-wt/numba-cpu-nnls-iteration-reduction/ (created by /start_library)

Implementation Steps

PyAutoArray — autoarray/util/fnnls.py (fnnls_cholesky, lines 25-167; fix_constraint_cholesky 170-207)

  1. Add stats: Optional[dict] = None; when given, fill outer_iterations, inner_iterations, passive_set (int index array, final P_inorder), n_passive, warm_start_errors (= |P_initial ⊕ P_final|). No change to the returned d.
  2. Accept P_initial as a bool mask or an index array (production passes a mask, test_cholesky_inplace.py:155 passes indices) — normalise once at line ~47.
  3. Warm-start factorisation reuse: today the P_initial branch (lines 83-89) does a dense scipy solve for d and then the first outer iteration still rebuilds U_buffer with a full slg.cholesky (106-110). Seed U_buffer[:k,:k] from one slg.cholesky(ZTZ[P_inorder][:,P_inorder]) and derive d from it via _cho_solve_buffer — one factorisation instead of a solve + a factorisation. Bit-level: solution may differ at ulp level vs the scipy solve; pins at rtol 1e-6 are the guard.

PyAutoArray — autoarray/settings.py (fields lines 10-21; assignments 152-154)
4. Add nnls_warm_start_memo: Optional[bool] = None (resolve None from conf.instance["general"]["inversion"]["nnls_warm_start_memo"], default false in autoarray/config/general.yaml until measured). Docstring: numpy/numba path only.

PyAutoArray — new autoarray/inversion/inversion/nnls_memo.py
5. _nnls_passive_set_memo: Dict[str, np.ndarray], cap 8, FIFO; memo_key(n, fingerprint); env AUTOARRAY_NNLS_WARM_START=0 disables; stored arrays copied + setflags(write=False). Mirror the contract of imaging_numba/sparse.py:20-52 (misses, never stale hits).

PyAutoArray — autoarray/inversion/inversion/inversion_util.py:366-386 (numpy branch of reconstruction_positive_only_from)
6. If settings.nnls_warm_start_memo and memo hit for (n, fingerprint): P_initial = memo passive set (validate all indices < n, else miss). Else today's dense-sign P_initial. Pass stats dict; on success store stats["passive_set"]. Fingerprint: the caller (abstract.py:554-601) passes fingerprint= derived from the mapper's source_plane_mesh_grid.shape + data_vector.shape (+ ids_to_keep when the edge-zeroed subset branch is taken — the passive set must be in the SUBSET index space in that branch).
7. Keep the existing except (RuntimeError, LinAlgError, ValueError)InversionException contract; a memo-seeded solve that raises must retry once from the dense-sign start before raising (a bad warm start must not turn into a resample).

Tests
8. test_autoarray/util/test_cholesky_inplace.py: stats populated and consistent (n_passive == len(passive_set), errors == 0 when warm-started from the true solution's support); warm start from a deliberately WRONG passive set converges to the cold-start solution (rel 1e-8); mask vs index P_initial equivalence; factorisation-seeded warm start matches previous behaviour to 1e-10.
9. test_autoarray/util/test_jax_nnls.py:39 (numpy_path_ignores_knobs): keep asserting nnls_solver_tol/nnls_max_iter are ignored; add that nnls_warm_start_memo is honoured (memo populated after one solve, second solve reports warm_start_errors == 0), and that AUTOARRAY_NNLS_WARM_START=0 disables.
10. test_autoarray/inversion/inversion/: memo-seeded inversion reconstruction equals un-memoed reconstruction (1e-10) on a small mapper fixture; subset (solve_ids_to_keep) branch covered.

Diagnostic + measurement (autolens_profiling, uncommitted until #183 merges)
11. Script scripts/imaging/likelihood_breakdown/delaunay_numba_nnls_iterations.py built from delaunay_numba.py's setup (lines 75-183; instance at 167 via model.instance_from_vector; rebuild adapt_images per instance — it is keyed on the instance's source galaxy). Sequences: (a) random walk, 30 steps, σ = 1% of prior width per parameter; (b) 30 i.i.d. draws from the priors' central 20%; run each with memo off / memo on; record per solve: outer/inner iterations, warm-start errors (dense-sign vs previous), solve seconds, log-likelihood. euclid + hst, AUTOARRAY_NUMBA_OPERATED_MEMO=0, OMP=1.
12. Decision gate on the numbers (≥2× median iteration reduction on (a) with zero pin drift → default true); post the table on this issue; then /start_workspace for the autolens_profiling leg once #183 lands (script + results JSON + note in results/notes/).

Key Files

  • PyAutoArray/autoarray/util/fnnls.pyfnnls_cholesky, warm-start seeding, stats
  • PyAutoArray/autoarray/util/cholesky_funcs.py — buffer kernels (cholinsertlast_inplace 192, choldeleteindexes_inplace 243, _cho_solve_buffer 178)
  • PyAutoArray/autoarray/inversion/inversion/inversion_util.py:256-386reconstruction_positive_only_from
  • PyAutoArray/autoarray/inversion/inversion/abstract.py:537-607reconstruction (subset branch 564-593)
  • PyAutoArray/autoarray/inversion/inversion/imaging_numba/sparse.py:20-52 — memo template
  • PyAutoArray/autoarray/settings.pySettings
  • autolens_profiling/scripts/imaging/likelihood_breakdown/delaunay_numba.py — fiducial setup; solve accessor line 231
  • autolens_profiling/results/notes/nnls_solver_ledger.md — prior BPP/warm-start findings (JAX PDIP path — do not transfer blindly)

Epic

numba-cpu-likelihood: profiling ✔, first-call bug ✔, phase 1 ✔ (#497/#588), phase 2b ✔ (#453/#463), phase 3a = this, phase 3b = batched active-set moves (to file after 3a's diagnostic), phase 2a kernel-CDF deferred. Measurement prerequisite: draft/feature/autolens_profiling/numba_breakdown_harness_memo_blind.md.

Original Prompt

Click to expand starting prompt

Numba CPU likelihood phase 3: cut the positive-only solve's active-set iterations (warm start across evaluations + block pivoting)

Type: feature
Epic: numba-cpu-likelihood
Phase: 3
Target: autoarray
Repos:

  • @PyAutoArray
  • @autolens_profiling
    Difficulty: large
    Autonomy: supervised
    Priority: high
    Status: formalised
    Filed: 2026-08-27

Phase 3 of the CPU-likelihood speed restoration. Phase 1 (MGE batching +
caching, PyAutoArray#497 / PyAutoGalaxy#588) and phase 2b (fnnls in-place
Cholesky buffer, PyAutoArray#453 / #463, backfilled record
complete/2026/08/numba-fnnls-inplace-cholesky-buffer.md) are shipped.
The "restore the deleted numba fnnls" idea is RETIRED (autolens_profiling#151
comment 5) — the solver was never missing. Do not re-file it.

Context (autolens_profiling#151 comments 5-6, PyAutoArray PR#453 text)

On the campaign fiducial (Delaunay + Hilbert AdaptImage + ConstantSplit,
apply_sparse_operator_cpu(), use_jax=False) the positive-only
reconstruction solve is the dominant term: euclid 3.6 s of 5.0 s (~72%) at
1250 source pixels, hst 1.4 s (~35%) — pre-#453 numbers; #453 reports the
solve at 1.40 s -> 1.16 s on its 1310-param system, so a re-profile on current
main is step 0.

The instrumented probe (comment 5) decomposed a 5.06 s solve at n=1560 as: dense
warm-start solve 0.10 s (1411/1560 positives correct) + initial Cholesky 0.06 s +
up/down-dates 3.58 s across 154 outer + 102 constraint-fix iterations +
cho_solve 0.99 s + w matvecs 0.26 s. One from-scratch Cholesky at n=1560 is
0.08 s. Comment 6: "the solve is iteration-bound, not resolution- or
param-bound"
— cost tracks how many entries the dense warm start gets wrong
(~150 at euclid, far fewer at hst). #453 removed the copy overhead per
iteration; the iteration COUNT is untouched.

Live solver: autoarray/util/fnnls.py::fnnls_cholesky(ZTZ, ZTx, P_initial)
(Bro & De Jong 1997 active set, Cholesky up/down-dating via
autoarray/util/cholesky_funcs.py numba kernels), called from
inversion_util.reconstruction_positive_only_from (numpy branch, ~line 371)
with P_initial = np.linalg.solve(curvature_reg_matrix, data_vector) > 0.
Tests: test_autoarray/util/test_cholesky_inplace.py,
test_cholesky_degenerate.py.

Goal

Reduce the number of active-set iterations per solve on the numba CPU path,
keeping the solution the unique NNLS optimum (curvature_reg is PD, so the
solution is unique; pinned log-likelihoods must hold at rtol 1e-6):

  1. Step 0 — re-profile on current main with the delaunay_numba
    breakdown (euclid + hst, 1250) so the baseline post-perf: in-place Cholesky buffer + copy-free numba solves for fnnls_cholesky #453 is recorded; add an
    iteration counter / per-solve diagnostic (outer + inner iterations, passive
    set size, entries the warm start got wrong) to the breakdown so the win is
    measured in iterations, not just seconds.
  2. Cross-evaluation warm start: seed P_initial from the previous
    evaluation's final passive set (same process; nearby parameter points share
    most of the active set) instead of the dense unconstrained solve's sign, with
    the dense solve as fallback when shapes change. Design decision in the plan:
    where the state lives (module-level like the operated-matrix memo, keyed on
    mapper shape; or a Settings field / preload passed through the analysis)
    and how multiprocessing workers behave (each worker keeps its own).
  3. Block pivoting (Portugal-Judice-Vicente style) or a batched
    constraint-fix step so each outer iteration moves many indices at once
    rather than one, bounded by a fallback to the current single-index rule to
    preserve convergence guarantees.
  4. Measure: iterations and solve seconds before/after at euclid + hst; pinned
    log-likelihood parity; test_autoarray green. Record in autolens_profiling
    (results + notes) and on the issue. Measure with
    draft/feature/autolens_profiling/numba_breakdown_harness_memo_blind.md
    landed or with AUTOARRAY_NUMBA_OPERATED_MEMO=0 (the harness's fixed instance
    would otherwise hide the MGE term and, for a cross-eval warm start, would
    fake a 100%-correct warm start — perturb the instance between repeats).

Out of scope: the JAX PDIP solver (jax_nnls.py), the kernel-CDF phase 2a.

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