Skip to content

Commit 2c9a22b

Browse files
committed
perf: in-place Cholesky buffer + copy-free numba solves for fnnls_cholesky
The positive-only inversion solve (fnnls_cholesky) dominates the numba CPU Delaunay likelihood (~74% of a euclid eval, autolens_profiling#151): ~150 Bro-Jong active-set iterations, each rebuilding the ~1300^2 factor via np.insert/np.delete (two O(n^2) copies per change) and re-scanning/copying it inside scipy's solve_triangular/cho_solve. Two changes, same mathematics: - The factor now lives in the top-left k x k corner of one preallocated buffer, grown/shrunk in place (cholinsertlast_inplace / choldeleteindexes_inplace; the numba Givens kernels are unchanged and proven bitwise-stable on strided views). - The per-iteration triangular solves run through new copy-free numba kernels (_solve_upper_transposed_buffer, _cho_solve_buffer) that read the buffer's upper triangle directly — scipy on a strided view re-copies and finite-scans O(k^2) per call, which had cancelled the buffer's win. On the real euclid 1310-param system (Delaunay Hilbert-1250 fiducial, autolens_profiling PR #152): solve 3.29 s -> 1.40 s (2.35x), identical active set, solutions agree to ~1e-9 absolute. Results are not bitwise identical to the old path (LAPACK picks layout-dependent but equally valid kernels; the old implementation already mixed layouts iteration-to-iteration) — equivalence is tested at 1e-13 in the new test_autoarray/util/test_cholesky_inplace.py, plus exact factor-property checks and scipy.optimize.nnls cross-checks. The out-of-place cholinsertlast/choldeleteindexes remain for reference and tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vcc7MUBMnNU6n8qqS9ioVZ
1 parent a26385b commit 2c9a22b

3 files changed

Lines changed: 310 additions & 17 deletions

File tree

autoarray/util/cholesky_funcs.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,3 +152,106 @@ def choldeleteindexes(U, indexes):
152152
U = L
153153

154154
return U
155+
156+
157+
@numba_util.jit()
158+
def _solve_upper_transposed_buffer(Ubuf, k, b):
159+
"""
160+
Solve ``U^T y = b`` for the active k x k upper factor ``Ubuf[:k, :k]``,
161+
overwriting ``b`` with ``y`` (row-oriented forward substitution, touching
162+
only contiguous row slices of the buffer's upper triangle).
163+
164+
This replaces `scipy.linalg.solve_triangular(..., trans=1)` on the buffer
165+
view: scipy copies a non-contiguous view into a fresh array and scans it
166+
for non-finite values on every call, which at n ~ 1000 and ~150 calls per
167+
fnnls solve re-creates the very memory traffic the in-place buffer
168+
removes.
169+
"""
170+
for i in range(k):
171+
yi = b[i] / Ubuf[i, i]
172+
b[i] = yi
173+
for j in range(i + 1, k):
174+
b[j] -= Ubuf[i, j] * yi
175+
return b
176+
177+
178+
@numba_util.jit()
179+
def _cho_solve_buffer(Ubuf, k, b):
180+
"""
181+
Solve ``(U^T U) s = b`` for the active k x k upper factor
182+
``Ubuf[:k, :k]``, overwriting ``b`` with ``s`` — LAPACK ``cho_solve`` for
183+
an upper factor, reading only the buffer's upper triangle and making no
184+
copies (see `_solve_upper_transposed_buffer` for why that matters).
185+
"""
186+
b = _solve_upper_transposed_buffer(Ubuf, k, b)
187+
for i in range(k - 1, -1, -1):
188+
b[i] = (b[i] - np.dot(Ubuf[i, i + 1 : k], b[i + 1 : k])) / Ubuf[i, i]
189+
return b
190+
191+
192+
def cholinsertlast_inplace(Ubuf, k, x):
193+
"""
194+
In-place variant of `cholinsertlast` for a factor held in a preallocated
195+
buffer: the active k x k factor is `Ubuf[:k, :k]` and the new row/column is
196+
written directly into row/column `k` of the buffer.
197+
198+
Same arithmetic as `cholinsertlast` (a transposed-triangular solve for
199+
the new column, the same `_pivot_from_schur` pivot, on the same values),
200+
but with a copy-free numba substitution in place of scipy's
201+
`solve_triangular` and without the two full O(k^2) `np.insert`
202+
reallocations per call — which dominate the
203+
positive-only inversion solve at n ~ 1000 (one such insertion per fnnls
204+
active-set iteration, ~150 iterations per likelihood evaluation).
205+
206+
Only the upper triangle of the active region is maintained; entries below
207+
the diagonal are never written or read (every consumer — the solve and
208+
update kernels above — references the upper triangle only). ``x[:k]`` is
209+
overwritten by the solve.
210+
211+
Returns the new active size ``k + 1``; the factor is ``Ubuf[:k+1, :k+1]``.
212+
"""
213+
S12 = _solve_upper_transposed_buffer(Ubuf, k, x[:k])
214+
215+
Ubuf[:k, k] = S12
216+
217+
Ubuf[k, k] = _pivot_from_schur(
218+
schur=x[k] - S12.dot(S12), diagonal=x[k], index=k
219+
)
220+
221+
return k + 1
222+
223+
224+
def choldeleteindexes_inplace(Ubuf, k, indexes):
225+
"""
226+
In-place variant of `choldeleteindexes` for a factor held in a
227+
preallocated buffer: remove the given positions from the active k x k
228+
factor `Ubuf[:k, :k]` by shifting the surviving rows/columns within the
229+
buffer, then re-triangularize the trailing block with the same numba
230+
Givens kernel (`_cholupdate`) on the same values — no `np.delete`
231+
reallocations (two full-factor copies per deleted index).
232+
233+
NumPy guarantees correct results for the overlapping same-array slice
234+
assignments used for the shifts (it buffers the source when views
235+
overlap). Only the upper triangle is maintained, as in
236+
`cholinsertlast_inplace`.
237+
238+
Returns the new active size; the factor is ``Ubuf[:k', :k']``.
239+
"""
240+
for index in sorted(indexes, reverse=True):
241+
# The deleted row's tail is the rank-1 update vector for the trailing
242+
# block — copied out before the shifts overwrite it.
243+
x = Ubuf[index, index + 1 : k].copy()
244+
245+
# Deleting row+column `index`: the top-right block shifts one column
246+
# left; the trailing block shifts one step up-left along the diagonal.
247+
Ubuf[:index, index : k - 1] = Ubuf[:index, index + 1 : k]
248+
Ubuf[index : k - 1, index : k - 1] = Ubuf[index + 1 : k, index + 1 : k]
249+
250+
k -= 1
251+
252+
# If the deleted index was at the end, the factor needs no update.
253+
254+
if index < k:
255+
_cholupdate(Ubuf[index:k, index:k], x)
256+
257+
return k

autoarray/util/fnnls.py

Lines changed: 38 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import numpy as np
22

3-
from autoarray.util.cholesky_funcs import cholinsertlast, choldeleteindexes
3+
from autoarray.util.cholesky_funcs import (
4+
_cho_solve_buffer,
5+
cholinsertlast_inplace,
6+
choldeleteindexes_inplace,
7+
)
48

59
from autoarray import exc
610

@@ -50,6 +54,18 @@ def fnnls_cholesky(
5054
w = ZTx - (ZTZ) @ d
5155
s_chol = np.zeros(n)
5256

57+
# The Cholesky factor of ZTZ[passive][:, passive] lives in the top-left
58+
# k_active x k_active corner of a single preallocated buffer, updated in
59+
# place by cholinsertlast_inplace / choldeleteindexes_inplace as the
60+
# active set changes. The buffer is allocated once (first factorisation)
61+
# instead of the factor being rebuilt with np.insert/np.delete every
62+
# iteration — the dominant cost of this solver at n ~ 1000. Zeroed, not
63+
# empty: the update/solve kernels only ever read the upper triangle, but
64+
# keeping the rest exactly zero costs one memset and keeps every k x k
65+
# view a valid dense factor for inspection and tests.
66+
U_buffer = np.zeros((n, n))
67+
k_active = 0
68+
5369
if P_initial.shape[0] != 0:
5470
P_number = np.arange(len(P), dtype="int")
5571
P_inorder = P_number[P_initial]
@@ -76,22 +92,27 @@ def fnnls_cholesky(
7692
if loop_count == 0:
7793
# We need to initialize the Cholesky factorisation, U, for the first loop.
7894
U = slg.cholesky(ZTZ[P_inorder][:, P_inorder])
95+
k_active = U.shape[0]
96+
U_buffer[:k_active, :k_active] = U
7997
else:
80-
U = cholinsertlast(U, ZTZ[idmax][P_inorder])
98+
k_active = cholinsertlast_inplace(
99+
U_buffer, k_active, ZTZ[idmax][P_inorder]
100+
)
81101

82-
# solve the lstsq problem by cho_solve
102+
# solve the lstsq problem via the copy-free buffer cho_solve
83103

84-
s_chol[P_inorder] = slg.cho_solve((U, False), ZTx[P_inorder])
104+
s_chol[P_inorder] = _cho_solve_buffer(U_buffer, k_active, ZTx[P_inorder])
85105

86106
P[idmax] = True
87107
while np.any(P) and np.min(s_chol[P]) <= tolerance:
88-
s_chol, d, P, P_inorder, U = fix_constraint_cholesky(
108+
s_chol, d, P, P_inorder, k_active = fix_constraint_cholesky(
89109
ZTx=ZTx,
90110
s_chol=s_chol,
91111
d=d,
92112
P=P,
93113
P_inorder=P_inorder,
94-
U=U,
114+
U_buffer=U_buffer,
115+
k_active=k_active,
95116
tolerance=tolerance,
96117
)
97118

@@ -132,18 +153,18 @@ def fnnls_cholesky(
132153
return d
133154

134155

135-
def fix_constraint_cholesky(ZTx, s_chol, d, P, P_inorder, U, tolerance):
156+
def fix_constraint_cholesky(ZTx, s_chol, d, P, P_inorder, U_buffer, k_active, tolerance):
136157
"""
137158
Similar to fix_constraint, but solve the lstsq by Cholesky factorisation.
138159
If this function is called, it means some solutions in the current passive sets needed to be
139160
taken out and put into the active set.
140161
So, this function involves 3 procedure:
141162
1. Identifying what solutions should be taken out of the current passive set.
142-
2. Updating the P, P_inorder and the Cholesky factorisation U.
143-
3. Solving the lstsq by using the new Cholesky factorisation U.
163+
2. Updating the P, P_inorder and the Cholesky factorisation (the active
164+
k_active x k_active corner of U_buffer, updated in place).
165+
3. Solving the lstsq by using the new Cholesky factorisation.
144166
As some solutions are taken out from the passive set, the Cholesky factorisation needs to be
145-
updated by choldeleteindexes. To realize that, we call the `choldeleteindexes` from
146-
cholesky_funcs.
167+
updated in place by `choldeleteindexes_inplace` from cholesky_funcs.
147168
"""
148169
q = P * (s_chol <= tolerance)
149170
alpha = np.min(d[q] / (d[q] - s_chol[q]))
@@ -153,20 +174,20 @@ def fix_constraint_cholesky(ZTx, s_chol, d, P, P_inorder, U, tolerance):
153174

154175
id_delete = np.where(d[P_inorder] <= tolerance)[0]
155176

156-
U = choldeleteindexes(U, id_delete) # update the Cholesky factorisation
177+
# update the Cholesky factorisation
178+
179+
k_active = choldeleteindexes_inplace(U_buffer, k_active, id_delete)
157180

158181
P_inorder = np.delete(P_inorder, id_delete) # update the P_inorder
159182

160183
P[d <= tolerance] = False # update the P
161184

162-
# solve the lstsq problem by cho_solve
185+
# solve the lstsq problem via the copy-free buffer cho_solve
163186

164187
if len(P_inorder):
165-
from scipy import linalg as slg
166-
167188
# there could be a case where P_inorder is empty.
168-
s_chol[P_inorder] = slg.cho_solve((U, False), ZTx[P_inorder])
189+
s_chol[P_inorder] = _cho_solve_buffer(U_buffer, k_active, ZTx[P_inorder])
169190

170191
s_chol[~P] = 0.0 # set solutions taken out of the passive set to be 0
171192

172-
return s_chol, d, P, P_inorder, U
193+
return s_chol, d, P, P_inorder, k_active
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
"""
2+
The in-place Cholesky update path (`cholinsertlast_inplace` /
3+
`choldeleteindexes_inplace` + the preallocated buffer in `fnnls_cholesky`)
4+
must reproduce the out-of-place `cholinsertlast` / `choldeleteindexes`
5+
results to the last few ulp, and the maintained factor must stay an exact
6+
Cholesky factor of the active submatrix.
7+
8+
Exact bitwise agreement between the two implementations is NOT required (and
9+
does not hold): LAPACK picks different, equally valid dtrtrs/dpotrs
10+
invocations depending on the input's memory layout (F-contiguous vs
11+
C-contiguous vs strided view), producing last-ulp differences — the
12+
out-of-place implementation already mixes layouts between its own iterations
13+
(`scipy.linalg.cholesky` returns F-order, `np.insert`/`np.delete` return
14+
C-order). The production tolerance for this solver's output is the profiling
15+
pins' rtol=1e-6; the cross-implementation tolerance here is far tighter.
16+
"""
17+
18+
import numpy as np
19+
import pytest
20+
from scipy import linalg as slg
21+
from scipy.optimize import nnls
22+
23+
from autoarray.util.cholesky_funcs import (
24+
cholinsertlast,
25+
cholinsertlast_inplace,
26+
choldeleteindexes,
27+
choldeleteindexes_inplace,
28+
)
29+
from autoarray.util.fnnls import fnnls_cholesky
30+
31+
32+
def _random_spd(n, seed):
33+
rng = np.random.default_rng(seed)
34+
Z = rng.normal(size=(2 * n, n))
35+
return Z.T @ Z + n * np.eye(n)
36+
37+
38+
def _buffer_from(U, n_max):
39+
buffer = np.zeros((n_max, n_max))
40+
k = U.shape[0]
41+
buffer[:k, :k] = U
42+
return buffer, k
43+
44+
45+
def _assert_factors_match(U_reference, U_inplace):
46+
"""The two factors agree to within a few ulp (see module docstring)."""
47+
np.testing.assert_allclose(
48+
np.triu(U_inplace), np.triu(U_reference), rtol=1e-13, atol=1e-13
49+
)
50+
51+
52+
def _assert_is_cholesky_of(U_view, A_sub):
53+
"""The maintained upper triangle is an exact Cholesky factor of the
54+
active submatrix (the property every downstream cho_solve relies on)."""
55+
R = np.triu(U_view)
56+
np.testing.assert_allclose(R.T @ R, A_sub, rtol=1e-12, atol=1e-12)
57+
58+
59+
@pytest.mark.parametrize("seed", [0, 1, 2])
60+
def test__cholinsertlast_inplace__matches_out_of_place(seed):
61+
n = 12
62+
A = _random_spd(n, seed)
63+
64+
k = 7
65+
U = slg.cholesky(A[:k, :k])
66+
x = A[k, : k + 1].copy()
67+
68+
S = cholinsertlast(U.copy(), x.copy())
69+
70+
buffer, k_active = _buffer_from(U, n)
71+
k_active = cholinsertlast_inplace(buffer, k_active, x.copy())
72+
73+
assert k_active == k + 1
74+
_assert_factors_match(S, buffer[:k_active, :k_active])
75+
_assert_is_cholesky_of(buffer[:k_active, :k_active], A[: k + 1, : k + 1])
76+
77+
78+
@pytest.mark.parametrize(
79+
"indexes",
80+
[[0], [7], [3, 5], [0, 1, 6], [2, 3, 4, 5], [0, 1, 2, 3, 4, 5, 6, 7]],
81+
)
82+
def test__choldeleteindexes_inplace__matches_out_of_place(indexes):
83+
n = 12
84+
A = _random_spd(n, seed=3)
85+
86+
k = 8
87+
U = slg.cholesky(A[:k, :k])
88+
89+
L = choldeleteindexes(U.copy(), list(indexes))
90+
91+
buffer, k_active = _buffer_from(U, n)
92+
k_active = choldeleteindexes_inplace(buffer, k_active, list(indexes))
93+
94+
assert k_active == k - len(indexes)
95+
_assert_factors_match(L, buffer[:k_active, :k_active])
96+
97+
keep = [i for i in range(k) if i not in indexes]
98+
_assert_is_cholesky_of(
99+
buffer[:k_active, :k_active], A[np.ix_(keep, keep)]
100+
)
101+
102+
103+
def test__interleaved_inserts_and_deletes__match():
104+
n = 20
105+
A = _random_spd(n, seed=4)
106+
107+
k = 4
108+
U = slg.cholesky(A[:k, :k])
109+
buffer, k_active = _buffer_from(U, n)
110+
111+
# Mimic fnnls's usage: grow to the next leading size, shed some indexes,
112+
# grow again — comparing the two implementations after every operation.
113+
for op, arg in [
114+
("insert", None),
115+
("insert", None),
116+
("delete", [1, 3]),
117+
("insert", None),
118+
("delete", [0]),
119+
("insert", None),
120+
("insert", None),
121+
]:
122+
if op == "insert":
123+
k_old = U.shape[0]
124+
x = A[k_old, : k_old + 1].copy()
125+
U = cholinsertlast(U, x.copy())
126+
k_active = cholinsertlast_inplace(buffer, k_active, x.copy())
127+
else:
128+
U = choldeleteindexes(U, arg)
129+
k_active = choldeleteindexes_inplace(buffer, k_active, arg)
130+
131+
assert k_active == U.shape[0]
132+
_assert_factors_match(U, buffer[:k_active, :k_active])
133+
134+
135+
@pytest.mark.parametrize("seed", [0, 1, 2, 3, 4])
136+
def test__fnnls_cholesky__matches_scipy_nnls(seed):
137+
rng = np.random.default_rng(seed)
138+
n = 30
139+
Z = rng.normal(size=(50, n))
140+
# A mixed-sign target so a substantial subset of the solution is clamped
141+
# at zero and the delete path is exercised.
142+
x = Z @ rng.normal(size=n) + rng.normal(size=50)
143+
144+
ZTZ = Z.T @ Z
145+
ZTx = Z.T @ x
146+
147+
d = fnnls_cholesky(ZTZ, ZTx)
148+
d_ref, _ = nnls(Z, x)
149+
150+
assert np.all(d >= 0.0)
151+
assert d == pytest.approx(d_ref, rel=1e-6, abs=1e-8)
152+
153+
154+
@pytest.mark.parametrize("seed", [0, 1, 2])
155+
def test__fnnls_cholesky__warm_start_matches_cold_start(seed):
156+
rng = np.random.default_rng(seed)
157+
n = 30
158+
Z = rng.normal(size=(50, n))
159+
x = Z @ rng.normal(size=n) + rng.normal(size=50)
160+
161+
ZTZ = Z.T @ Z
162+
ZTx = Z.T @ x
163+
164+
d_cold = fnnls_cholesky(ZTZ, ZTx)
165+
166+
P_initial = np.where(slg.solve(ZTZ.copy(), ZTx.copy(), assume_a="pos") > 0)[0]
167+
d_warm = fnnls_cholesky(ZTZ, ZTx, P_initial=P_initial)
168+
169+
assert d_warm == pytest.approx(d_cold, rel=1e-8, abs=1e-10)

0 commit comments

Comments
 (0)