You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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.
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.
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/).
@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.
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):
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.
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).
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.
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.
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, andfnnls_choleskyadds 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 (recordcomplete/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
fnnls_cholesky(final passive set, outer/inner iteration counts, warm-start error count) via an optionalstatsdict — default return unchanged.AUTOARRAY_NUMBA_OPERATED_MEMO=0.imaging_numba/sparse.py:20-52), keyed on problem size + mesh fingerprint,Settingsknob +AUTOARRAY_NNLS_WARM_START=0kill-switch, dense-sign fallback on miss. Fork-based pools give each worker its own memo automatically.slg.cholesky; skip the separate densenp.linalg.solvewhen a warm passive set is available.autolens_profilingleg (diagnostic script + results + notes) ships afterharvest-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
Branch Survey
Suggested branch:
feature/numba-cpu-nnls-iteration-reductionWorktree 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_cholesky170-207)stats: Optional[dict] = None; when given, fillouter_iterations,inner_iterations,passive_set(int index array, finalP_inorder),n_passive,warm_start_errors(= |P_initial ⊕ P_final|). No change to the returnedd.P_initialas a bool mask or an index array (production passes a mask,test_cholesky_inplace.py:155passes indices) — normalise once at line ~47.P_initialbranch (lines 83-89) does a dense scipy solve fordand then the first outer iteration still rebuildsU_bufferwith a fullslg.cholesky(106-110). SeedU_buffer[:k,:k]from oneslg.cholesky(ZTZ[P_inorder][:,P_inorder])and derivedfrom it via_cho_solve_buffer— one factorisation instead of a solve + a factorisation. Bit-level: solution may differ at ulp level vs the scipysolve; 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(resolveNonefromconf.instance["general"]["inversion"]["nnls_warm_start_memo"], defaultfalseinautoarray/config/general.yamluntil measured). Docstring: numpy/numba path only.PyAutoArray — new
autoarray/inversion/inversion/nnls_memo.py5.
_nnls_passive_set_memo: Dict[str, np.ndarray], cap 8, FIFO;memo_key(n, fingerprint); envAUTOARRAY_NNLS_WARM_START=0disables; stored arrays copied +setflags(write=False). Mirror the contract ofimaging_numba/sparse.py:20-52(misses, never stale hits).PyAutoArray —
autoarray/inversion/inversion/inversion_util.py:366-386(numpy branch ofreconstruction_positive_only_from)6. If
settings.nnls_warm_start_memoand memo hit for(n, fingerprint):P_initial = memo passive set(validate all indices < n, else miss). Else today's dense-signP_initial. Passstatsdict; on success storestats["passive_set"]. Fingerprint: the caller (abstract.py:554-601) passesfingerprint=derived from the mapper'ssource_plane_mesh_grid.shape+data_vector.shape(+ids_to_keepwhen 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)→InversionExceptioncontract; 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 indexP_initialequivalence; factorisation-seeded warm start matches previous behaviour to 1e-10.9.
test_autoarray/util/test_jax_nnls.py:39(numpy_path_ignores_knobs): keep assertingnnls_solver_tol/nnls_max_iterare ignored; add thatnnls_warm_start_memois honoured (memo populated after one solve, second solve reportswarm_start_errors == 0), and thatAUTOARRAY_NNLS_WARM_START=0disables.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.pybuilt fromdelaunay_numba.py's setup (lines 75-183; instance at 167 viamodel.instance_from_vector; rebuildadapt_imagesper 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_workspacefor the autolens_profiling leg once #183 lands (script + results JSON + note inresults/notes/).Key Files
PyAutoArray/autoarray/util/fnnls.py—fnnls_cholesky, warm-start seeding, statsPyAutoArray/autoarray/util/cholesky_funcs.py— buffer kernels (cholinsertlast_inplace192,choldeleteindexes_inplace243,_cho_solve_buffer178)PyAutoArray/autoarray/inversion/inversion/inversion_util.py:256-386—reconstruction_positive_only_fromPyAutoArray/autoarray/inversion/inversion/abstract.py:537-607—reconstruction(subset branch 564-593)PyAutoArray/autoarray/inversion/inversion/imaging_numba/sparse.py:20-52— memo templatePyAutoArray/autoarray/settings.py—Settingsautolens_profiling/scripts/imaging/likelihood_breakdown/delaunay_numba.py— fiducial setup; solve accessor line 231autolens_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:
Difficulty: large
Autonomy: supervised
Priority: high
Status: formalised
Filed: 2026-08-27
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-onlyreconstruction 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
mainis 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_solve0.99 s +wmatvecs 0.26 s. One from-scratch Cholesky at n=1560 is0.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.pynumba kernels), called frominversion_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):
delaunay_numbabreakdown (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.
P_initialfrom the previousevaluation'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
Settingsfield / preload passed through the analysis)and how multiprocessing workers behave (each worker keeps its own).
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.
log-likelihood parity;
test_autoarraygreen. Record in autolens_profiling(results + notes) and on the issue. Measure with
draft/feature/autolens_profiling/numba_breakdown_harness_memo_blind.mdlanded or with
AUTOARRAY_NUMBA_OPERATED_MEMO=0(the harness's fixed instancewould 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.