Skip to content

Commit c7330a7

Browse files
authored
Merge pull request #453 from PyAutoLabs/feature/fnnls-inplace-cholesky-buffer
perf: in-place Cholesky buffer + copy-free numba solves for fnnls_cholesky
2 parents a26385b + 89be276 commit c7330a7

3 files changed

Lines changed: 323 additions & 17 deletions

File tree

autoarray/util/cholesky_funcs.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,3 +152,119 @@ 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+
@numba_util.jit()
225+
def _choldelete_shift_buffer(Ubuf, k, index):
226+
"""
227+
Shift the active k x k factor's rows/columns to close the gap left by
228+
deleting row+column ``index``: the top-right block moves one column left,
229+
the trailing block moves one step up-left along the diagonal. Pure value
230+
movement (bitwise), touching only the upper triangle — the numba loops
231+
write destinations strictly behind their sources, so no temporary is
232+
needed (numpy's overlapping slice assignment buffers the source instead,
233+
which at ~100 deletes per fnnls solve is real allocation traffic).
234+
"""
235+
for i in range(index):
236+
for j in range(index, k - 1):
237+
Ubuf[i, j] = Ubuf[i, j + 1]
238+
for i in range(index, k - 1):
239+
for j in range(i, k - 1):
240+
Ubuf[i, j] = Ubuf[i + 1, j + 1]
241+
242+
243+
def choldeleteindexes_inplace(Ubuf, k, indexes):
244+
"""
245+
In-place variant of `choldeleteindexes` for a factor held in a
246+
preallocated buffer: remove the given positions from the active k x k
247+
factor `Ubuf[:k, :k]` by shifting the surviving rows/columns within the
248+
buffer (`_choldelete_shift_buffer`), then re-triangularize the trailing
249+
block with the same numba Givens kernel (`_cholupdate`) on the same
250+
values — no `np.delete` reallocations (two full-factor copies per
251+
deleted index). Only the upper triangle is maintained, as in
252+
`cholinsertlast_inplace`.
253+
254+
Returns the new active size; the factor is ``Ubuf[:k', :k']``.
255+
"""
256+
for index in sorted(indexes, reverse=True):
257+
# The deleted row's tail is the rank-1 update vector for the trailing
258+
# block — copied out before the shifts overwrite it.
259+
x = Ubuf[index, index + 1 : k].copy()
260+
261+
_choldelete_shift_buffer(Ubuf, k, index)
262+
263+
k -= 1
264+
265+
# If the deleted index was at the end, the factor needs no update.
266+
267+
if index < k:
268+
_cholupdate(Ubuf[index:k, index:k], x)
269+
270+
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

0 commit comments

Comments
 (0)