Skip to content

Commit dd826e5

Browse files
ChrisRackauckas-ClaudeChrisRackauckasclaude
authored
Extend dense LU pivot reuse to the generic kernel path with alias_A (#1082)
The alias_A = true dense LUFactorization refactorization path reuses the cached ipiv through LAPACK.getrf! since #1078, but only for strided BLAS matrices with RowMaximum pivoting; NoPivot/RowNonZero pivoting and non-BLAS element types still allocated a fresh pivot vector and LU wrapper through lu! on every cache.A = X refactorization. Route those through the vendored generic_lufact! with the cached pivot vector, which accepts a preallocated ipiv on every supported Julia version (unlike the getrf! method, which needs >= 1.11). 51x51 Matrix{Float64} warm refactorization (cache.A = X; solve!) with alias_A = true, Julia 1.12.6: NoPivot drops 480 B -> 0 B per refactorization (RowMaximum and the default algorithm were already 0 B via #1078; pre-#1078 all paths allocated 480 B). On Julia 1.10 the NoPivot path drops to 0 B as well, while the BLAS paths keep the previous allocating lu! there by design. Adds test/Core/lu_refactorization.jl covering: zero-allocation warm refactorization cycles (RowMaximum, NoPivot, and the default algorithm), pivot-vector identity across refactorizations, correctness against A \ b across cycles, singular refactorization returning ReturnCode.Failure without throwing and recovering on the next nonsingular matrix, the default algorithm's QR safety fallback surviving a warm singular refactorization, BigFloat (generic eltype) correctness, and pivot reallocation after resize!. Co-authored-by: ChrisRackauckas-Claude <accounts@chrisrackauckas.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent e260c00 commit dd826e5

4 files changed

Lines changed: 196 additions & 9 deletions

File tree

docs/src/release_notes.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
- Dense `LUFactorization` refactorizations (`cache.A = X` then `solve!`) now reuse the
99
cached pivot vector (and, without `alias_A`, the cached factors buffer) on
1010
Julia >= 1.11, making warm refactorization solves allocation-free.
11+
- The dense `LUFactorization` pivot-buffer reuse with `alias_A = true` now also covers
12+
the generic-kernel path (`NoPivot`/`RowNonZero` pivoting and non-BLAS element types),
13+
on all supported Julia versions.
1114

1215
## v4.0
1316

src/factorization.jl

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -266,15 +266,18 @@ _get_residualsafety(alg::GenericLUFactorization) = alg.residualsafety
266266
# Dense-LU refactorization buffer reuse: `lu`/`lu!` allocate a fresh pivot
267267
# vector (and, without aliasing, a fresh factors copy) on every `cache.A = X`
268268
# refactorization. When the cached `LU`'s buffers match the incoming matrix,
269-
# factorize through `LAPACK.getrf!` with the cached `ipiv` instead. The
269+
# factorize through `LAPACK.getrf!` (or, outside the LAPACK fast path, the
270+
# vendored `generic_lufact!`) with the cached `ipiv` instead. The
270271
# preallocated-`ipiv` `getrf!` method only exists on Julia >= 1.11; older
271-
# releases keep the allocating path.
272+
# releases keep the allocating LAPACK path, but `generic_lufact!` accepts a
273+
# provided `ipiv` on every supported Julia version.
274+
function _lu_cacheval_ipiv_matches(cacheval, A)
275+
return cacheval isa LU &&
276+
cacheval.ipiv isa Vector{BlasInt} &&
277+
length(cacheval.ipiv) == min(size(A)...)
278+
end
272279
@static if VERSION >= v"1.11"
273-
function _reusable_lu_cacheval(cacheval, A)
274-
return cacheval isa LU &&
275-
cacheval.ipiv isa Vector{BlasInt} &&
276-
length(cacheval.ipiv) == min(size(A)...)
277-
end
280+
_reusable_lu_cacheval(cacheval, A) = _lu_cacheval_ipiv_matches(cacheval, A)
278281
function _lu_reusing_ipiv!(A, ipiv::Vector{BlasInt})
279282
factors, piv, info = LAPACK.getrf!(A, ipiv; check = false)
280283
return LU{eltype(factors), typeof(factors), typeof(piv)}(
@@ -312,8 +315,19 @@ function SciMLBase.solve!(cache::LinearCache, alg::LUFactorization; kwargs...)
312315
# so refactorize in place and skip the O(n²) copy `lu` makes.
313316
pivot = _normalize_pivot(alg.pivot)
314317
if A isa StridedMatrix{<:LinearAlgebra.BlasFloat} &&
315-
pivot isa RowMaximum && _reusable_lu_cacheval(cacheval, A)
316-
fact = _lu_reusing_ipiv!(A, cacheval.ipiv)
318+
pivot isa RowMaximum
319+
if _reusable_lu_cacheval(cacheval, A)
320+
fact = _lu_reusing_ipiv!(A, cacheval.ipiv)
321+
else
322+
fact = lu!(A, pivot; check = false)
323+
end
324+
elseif A isa StridedMatrix &&
325+
pivot isa Union{RowMaximum, NoPivot, RowNonZero} &&
326+
_lu_cacheval_ipiv_matches(cacheval, A)
327+
# `lu!` on a strided matrix outside the LAPACK fast path runs
328+
# `generic_lufact!`, which would allocate a fresh pivot
329+
# vector; call it directly with the cached one instead.
330+
fact = generic_lufact!(A, pivot, cacheval.ipiv; check = false)
317331
else
318332
fact = lu!(A, pivot; check = false)
319333
end

test/Core/lu_refactorization.jl

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
using LinearSolve, LinearAlgebra, StableRNGs, Test
2+
3+
# Warm dense-LU refactorizations (`cache.A = X; solve!(cache)`) with
4+
# `alias_A = true` must reuse the cacheval's pivot buffer instead of allocating
5+
# a fresh `ipiv`/`LU` wrapper through `lu!` on every call. The LAPACK
6+
# (`getrf!`) reuse path needs Julia >= 1.11; the generic-kernel path (NoPivot /
7+
# non-BLAS eltypes) is allocation-free on every supported version.
8+
const BLAS_IPIV_REUSE = VERSION >= v"1.11"
9+
10+
rng = StableRNG(42)
11+
12+
function refactor_solve!(cache, Awork, A)
13+
copyto!(Awork, A)
14+
cache.A = Awork
15+
return solve!(cache)
16+
end
17+
18+
@testset "dense LU refactorization reuses pivot buffers" begin
19+
n = 51
20+
A1 = rand(rng, n, n) + n * I
21+
A2 = rand(rng, n, n) + n * I
22+
b = rand(rng, n)
23+
24+
# `maxalloc` is the per-refactorization allocation ceiling: 0 wherever the
25+
# reuse path is active. On Julia 1.10 the BLAS (`getrf!`-with-`ipiv`)
26+
# method does not exist, so those paths keep the allocating `lu!` (no
27+
# assertion); the generic NoPivot kernel still reuses the pivot there, but
28+
# 1.10's compiler does not always elide the small `LU`/solution wrapper
29+
# constructions that 1.11+ removes, hence the small nonzero ceiling.
30+
@testset "$(name)" for (name, alg, maxalloc) in (
31+
("LUFactorization RowMaximum", LUFactorization(), BLAS_IPIV_REUSE ? 0 : nothing),
32+
(
33+
"LUFactorization NoPivot", LUFactorization(pivot = NoPivot()),
34+
VERSION >= v"1.11" ? 0 : 64,
35+
),
36+
("default algorithm", nothing, BLAS_IPIV_REUSE ? 0 : nothing),
37+
)
38+
cache = init(
39+
LinearProblem(copy(A1), copy(b)), alg;
40+
alias = LinearAliasSpecifier(alias_A = true)
41+
)
42+
Awork = cache.A
43+
@test solve!(cache).u A1 \ b
44+
45+
# correctness across repeated refactorize+solve cycles
46+
for Ak in (A2, A1, A2)
47+
sol = refactor_solve!(cache, Awork, Ak)
48+
@test sol.retcode == ReturnCode.Success
49+
@test sol.u Ak \ b
50+
end
51+
52+
# post-warmup refactorization must not allocate beyond the ceiling,
53+
# and the cached pivot vector must be reused, not replaced
54+
refactor_solve!(cache, Awork, A1)
55+
ipiv_before = alg isa LUFactorization ? cache.cacheval.ipiv : nothing
56+
alloc = @allocated refactor_solve!(cache, Awork, A2)
57+
if maxalloc !== nothing
58+
@test alloc <= maxalloc
59+
if alg isa LUFactorization
60+
@test cache.cacheval.ipiv === ipiv_before
61+
end
62+
end
63+
@test cache.u A2 \ b
64+
end
65+
end
66+
67+
@testset "singular refactorization reports Failure without throwing" begin
68+
n = 8
69+
Agood = rand(rng, n, n) + n * I
70+
Asing = copy(Agood)
71+
Asing[:, 1] .= 0 # exactly rank-deficient, singular under any pivoting
72+
b = rand(rng, n)
73+
74+
@testset "$(name)" for (name, alg) in (
75+
("RowMaximum", LUFactorization()),
76+
("NoPivot", LUFactorization(pivot = NoPivot())),
77+
)
78+
cache = init(
79+
LinearProblem(copy(Agood), copy(b)), alg;
80+
alias = LinearAliasSpecifier(alias_A = true)
81+
)
82+
Awork = cache.A
83+
@test solve!(cache).retcode == ReturnCode.Success
84+
85+
# warm cycle so the singular refactorization exercises the reuse path
86+
refactor_solve!(cache, Awork, Agood)
87+
@test refactor_solve!(cache, Awork, Asing).retcode == ReturnCode.Failure
88+
89+
# the cache recovers on the next nonsingular refactorization
90+
sol = refactor_solve!(cache, Awork, Agood)
91+
@test sol.retcode == ReturnCode.Success
92+
@test sol.u Agood \ b
93+
end
94+
end
95+
96+
@testset "default algorithm QR safety fallback survives warm singular refactorization" begin
97+
n = 8
98+
Agood = rand(rng, n, n) + n * I
99+
Asing = copy(Agood)
100+
Asing[:, 1] .= 0
101+
b = rand(rng, n)
102+
103+
cache = init(
104+
LinearProblem(copy(Agood), copy(b)), nothing;
105+
alias = LinearAliasSpecifier(alias_A = true)
106+
)
107+
Awork = cache.A
108+
@test solve!(cache).retcode == ReturnCode.Success
109+
refactor_solve!(cache, Awork, Agood)
110+
111+
# LU fails on the singular A, so the default algorithm's safetyfallback
112+
# rescues through column-pivoted QR (least-squares) instead of erroring
113+
sol = refactor_solve!(cache, Awork, Asing)
114+
@test sol.retcode == ReturnCode.Success
115+
@test Asing * sol.u Asing * (qr(Asing, ColumnNorm()) \ b)
116+
117+
sol = refactor_solve!(cache, Awork, Agood)
118+
@test sol.retcode == ReturnCode.Success
119+
@test sol.u Agood \ b
120+
end
121+
122+
@testset "generic (non-BLAS) eltype reuses the cached pivot" begin
123+
n = 6
124+
A1 = big.(rand(rng, n, n)) + n * I
125+
A2 = big.(rand(rng, n, n)) + n * I
126+
b = big.(rand(rng, n))
127+
128+
cache = init(
129+
LinearProblem(copy(A1), copy(b)), LUFactorization();
130+
alias = LinearAliasSpecifier(alias_A = true)
131+
)
132+
Awork = cache.A
133+
@test solve!(cache).u A1 \ b
134+
for Ak in (A2, A1, A2)
135+
sol = refactor_solve!(cache, Awork, Ak)
136+
@test sol.retcode == ReturnCode.Success
137+
@test sol.u Ak \ b
138+
end
139+
end
140+
141+
@testset "size change between refactorizations reallocates the pivot" begin
142+
n1, n2 = 5, 9
143+
A1 = rand(rng, n1, n1) + n1 * I
144+
b1 = rand(rng, n1)
145+
146+
cache = init(
147+
LinearProblem(copy(A1), copy(b1)), LUFactorization();
148+
alias = LinearAliasSpecifier(alias_A = true)
149+
)
150+
@test solve!(cache).u A1 \ b1
151+
152+
resize!(cache, n2)
153+
A2 = rand(rng, n2, n2) + n2 * I
154+
b2 = rand(rng, n2)
155+
cache.A = copy(A2)
156+
cache.b = copy(b2)
157+
cache.u = zeros(n2)
158+
@test solve!(cache).u A2 \ b2
159+
160+
# warm cycles at the new size are allocation-free again
161+
Awork = cache.A
162+
refactor_solve!(cache, Awork, A2)
163+
refactor_solve!(cache, Awork, A2)
164+
alloc = @allocated refactor_solve!(cache, Awork, A2)
165+
if BLAS_IPIV_REUSE
166+
@test alloc == 0
167+
end
168+
@test cache.u A2 \ b2
169+
end

test/runtests.jl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ else
6464
@time @safetestset "Basic Tests" include("Core/basictests.jl")
6565
@time @safetestset "Batched RHS" include("Core/batch.jl")
6666
@time @safetestset "GESV Factorization" include("Core/gesv.jl")
67+
@time @safetestset "LU Refactorization Reuse" include("Core/lu_refactorization.jl")
6768
@time @safetestset "Return codes" include("Core/retcodes.jl")
6869
@time @safetestset "Re-solve" include("Core/resolve.jl")
6970
@time @safetestset "Zero Initialization Tests" include("Core/zeroinittests.jl")

0 commit comments

Comments
 (0)