Skip to content

SupernodalLU: fix block factorization on the direct-BLAS backends - #1119

Merged
ChrisRackauckas merged 1 commit into
mainfrom
supernodal-lu-mac-block-cache
Jul 27, 2026
Merged

SupernodalLU: fix block factorization on the direct-BLAS backends#1119
ChrisRackauckas merged 1 commit into
mainfrom
supernodal-lu-mac-block-cache

Conversation

@ChrisRackauckas

Copy link
Copy Markdown
Member

Fixes both macOS CI failures from #1114. They have separate causes.

1. snlu is a MethodError on any Mac (the real bug)

MethodError: no method matching _lu_from_cacheval(::AppleAccelerateLUCache{Matrix{Float64}, Vector{Int32}, RefValue{Int32}})

_lu_from_cacheval pulls the block factorization back out of the dense block cache's cacheval, handling LU and Tuple. The direct-BLAS backends store neither: Apple Accelerate, BLIS, and OpenBLAS each keep a mutable *LUCache holding the factored matrix, the getrf pivots, and info, which _custom_cache_factorization wraps in an LU only on demand. On a Mac the default dense solver resolves to AppleAccelerateLUFactorization, so the default path fell straight into the hole.

Despite surfacing in the allocation test, this is not a test bug — plain snlu(A) on any Mac fails for any matrix with a supernode at or above dense_threshold:

julia> F = LinearSolve.SupernodalLU.snlu(poisson2d(30))
ERROR: MethodError: no method matching _lu_from_cacheval(::LinearSolve.AppleAccelerateLUCache{...})

_cache_lu! reads exactly .factors/.ipiv, with the same LAPACK semantics those caches use, so the cacheval is now handed back as-is rather than allocating a throwaway LU per block. Anything exposing neither field gets a real error message instead of a MethodError.

Why the existing tests missed it: test/Core/supernodal_lu.jl does using RecursiveFactorization, which makes the default dense solver resolve to RFLU (a Tuple cacheval) on every platform. test/qa/allocations.jl is the only place snlu runs without it — which is why a Mac-only bug surfaced in an allocation test.

The new testset names the platform's direct-BLAS backends explicitly, over a caching-heavy and a caching-sparse dense_threshold, and covers refactorization through the same caches. It fails with the original MethodError without the numeric.jl change.

2. @check_allocs findings on lts and pre

Different cause, and not macOS-specific. Every finding is in LinearAlgebra stdlib frames, none in SupernodalLU: ldiv!(UpperTriangular(...)) → a generic_trimatdiv! dynamic dispatch plus boxed SubArrays (triangular.jl:752), and mul! → boxed MulAddMul in gemv! (matmul.jl:446). Measured on macOS aarch64:

Julia static @check_allocs runtime @allocated
1.10 (lts) fails (18 findings) 0
1.11 fails (28) multi-RHS 6144 B
1.12 (julia 1) clean 0
1.13 (pre) fails (3) 0

The sweeps are genuinely allocation-free at runtime on every version in CI; only the static proof is version-fragile, and only above PANEL_BLAS_CUTOFF where the sweeps hand off to the stdlib. Mac is simply the only runner exercising anything but 1.12 — the Linux QA group runs julia 1 only, which is why this never appeared there.

Each assertion is pinned to the versions that can carry it rather than weakened everywhere. What the assertions exist to protect — that the sweeps carry no growth branch — is unchanged on every version.

Testing

macOS aarch64. test/qa/allocations.jl and test/Core/supernodal_lu.jl pass on 1.10, 1.11, and 1.12; runtime zero-allocation confirmed separately on 1.13 (AllocCheck itself does not compile on my local 1.13.0-beta1, unrelated LLVM issue). Accelerate-routed blocks match the LU path to ~1e-15 across dense_threshold 4/16/64, including refactorization. Runic reports no formatting changes.

One caveat I could not close locally: I have no Linux box here, so the claim that #2 is version-driven rather than platform-driven rests on the stack traces containing no platform-specific frames and on 1.12 being clean on this same Mac.

🤖 Generated with Claude Code

`snlu` on a Mac was a `MethodError` for any matrix with a supernode at or
above `dense_threshold`:

    MethodError: no method matching _lu_from_cacheval(::AppleAccelerateLUCache{...})

`_lu_from_cacheval` pulled the block factorization out of the dense cache's
cacheval, handling `LU` and `Tuple`.  The direct-BLAS backends store neither:
Apple Accelerate, BLIS, and OpenBLAS each keep a mutable `*LUCache` holding
the factored matrix, the getrf pivots, and `info`, which
`_custom_cache_factorization` wraps in an `LU` only on demand.  On a Mac the
*default* dense solver resolves to Apple Accelerate, so the default path fell
straight into the hole.

`_cache_lu!` reads exactly `.factors`/`.ipiv`, with the same LAPACK
semantics those caches use, so the cacheval is handed back as-is rather than
allocating a throwaway `LU` per block; anything exposing neither field now
gets a real error message instead of a `MethodError`.

`test/Core/supernodal_lu.jl` missed this because it loads
RecursiveFactorization, which makes the default resolve to RFLU (a `Tuple`
cacheval) everywhere.  The new testset names the platform's direct-BLAS
backends explicitly, over both a caching-heavy and a caching-sparse
`dense_threshold`, and covers refactorization through the same caches.

Also version-gates two assertions in test/qa/allocations.jl.  Above
`PANEL_BLAS_CUTOFF` the sweeps hand off to LinearAlgebra's `ldiv!`/`mul!`,
whose wrappers are not statically allocation-free on every release: 1.10 and
1.13 leave a `generic_trimatdiv!` dynamic dispatch plus boxed
`MulAddMul`/`SubArray` values that AllocCheck reports from stdlib frames, and
1.11 allocates them for real on the `SubArray`-matrix path the multi-RHS
sweep takes.  Runtime allocation is 0 on 1.10, 1.12, and 1.13, so each
assertion is pinned to the versions that can carry it rather than dropped.

Verified on macOS aarch64: qa/allocations.jl and Core/supernodal_lu.jl pass
on 1.10, 1.11, and 1.12; runtime zero-allocation confirmed on 1.13.  The new
testset fails with the original `MethodError` without the numeric.jl change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ChrisRackauckas
ChrisRackauckas merged commit 4c58ce2 into main Jul 27, 2026
57 of 63 checks passed
@ChrisRackauckas
ChrisRackauckas deleted the supernodal-lu-mac-block-cache branch July 27, 2026 08:41
@ChrisRackauckas-Claude

Copy link
Copy Markdown
Member

Ran this on Linux x86_64, which closes the caveat at the bottom of the description. Your version-driven explanation is right; it is not platform-driven.

Same two matrices as the testset, AllocCheck.check_allocs(_solve_panels!, ...) plus runtime @allocated:

Julia arch static findings runtime 1-RHS runtime 3-RHS
1.10.11 x86_64-Linux 18 0 B 0 B
1.12.6 x86_64-Linux 0 0 B 0 B

18 findings on 1.10 Linux is the same count you measured on 1.10 macOS aarch64, and 1.12 is clean on both. So the stdlib wrapper story holds and there is nothing Apple- or ARM-specific in it.

Two follow-ups, one of which I think matters more than the version pinning:

1. The group/version pairing is the actual root cause of the pre failure.

[QA]              versions = ["lts", "1"]
[AppleAccelerate] versions = ["lts", "1", "pre"]

test/runtests.jl:98 pulls qa/allocations.jl into the AppleAccelerate group, so AllocCheck runs on pre — which QA deliberately never does. Worth noting the coverage is even narrower than it looks: only one QA job actually materialises (QA (julia 1)), so on Linux the static proof is exercised on 1.12 alone, and it is green there only because QA/lts never runs. That is why this never showed up outside macOS.

Since allocations.jl mixes two different things — the runtime Accelerate refactorization contract (needs Apple hardware, fine on every version) and the AllocCheck static proofs (1.12-only) — splitting the AllocCheck testsets into a QA-only file would fix pre structurally, rather than encoding a version window in a file that a second group also includes. The AppleAccelerate group would keep the contract it exists for.

2. 1.11 → multi-RHS 6144 B is a real runtime allocation, not a proof artifact.

Every other cell in your table is a static-proof failure with runtime 0. That one is an actual allocation per multi-RHS solve, and RUNTIME_MULTIRHS_ZERO skips the assertion rather than fixing it — so a user on 1.11 gets a silent per-solve allocation on the SubArray-matrix path with no test watching it. 1.11 is not in the CI matrix (lts/1/pre = 1.10/1.12/1.13), so it costs nothing today, but it is a genuine regression for 1.11 users. I could not reproduce it myself — my 1.11 QA env failed to precompile AllocCheck — so I have not verified the 6144 B independently. Worth at least an issue so it does not get lost behind the gate.

The _lu_from_cacheval fix itself looks right to me, and the diagnosis is much better than the one I was chasing (I had assumed an AllocCheck portability artifact and was pushing a CI diagnostic branch; closed it). One small note: the new generic method now catches nothing too, which lands in the ArgumentError with a clear message rather than a MethodError — good, just confirming that was intended.

ChrisRackauckas-Claude pushed a commit to ChrisRackauckas-Claude/LinearSolve.jl that referenced this pull request Jul 27, 2026
`test/runtests.jl` includes `qa/allocations.jl` from two groups, and they do not
cover the same Julia versions:

    [QA]              versions = ["lts", "1"]
    [AppleAccelerate] versions = ["lts", "1", "pre"]

So AllocCheck ran on `pre` through a group that was never meant to carry it.

The file mixes two things with different portability. The direct-BLAS
refactorization proofs hold on every release - that contract is why the
AppleAccelerate group exists. The SupernodalLU sweep proof does not: above
`PANEL_BLAS_CUTOFF` the sweeps hand off to LinearAlgebra's `ldiv!`/`mul!`,
whose wrappers leave a `generic_trimatdiv!` dynamic dispatch plus boxed
`MulAddMul`/`SubArray` values that AllocCheck reports on 1.10 and 1.13. Only
the second one belongs behind QA's version set, so only it moves.

Measured, same two matrices as the testset:

    Julia 1.10.11  x86_64-Linux   18 static findings   runtime 0 B / 0 B
    Julia 1.12.6   x86_64-Linux    0 static findings   runtime 0 B / 0 B

18 findings on 1.10 x86_64-Linux is the same count SciML#1119 measured on 1.10
aarch64-Darwin, and 1.12 is clean on both, so this is the stdlib and the Julia
version - not the platform, and not this package's code. Worth noting the
coverage is narrower than the toml suggests: only one QA job materialises
(`QA (julia 1)`), so the static proof runs on 1.12 alone.

The static proof keeps a version guard, since that is a limit of the prover.
`RUNTIME_MULTIRHS_ZERO` is dropped: it gated a *runtime* allocation assertion,
and on 1.11 that assertion fails because the multi-RHS path really does
allocate (6144 B, per SciML#1119's table). Skipping it hides a regression rather
than a prover limitation, so it should fail there until the allocation is
fixed.

Verified: `qa/allocations.jl` completes on Julia 1.10, the version that was
failing; `GROUP=QA` and `GROUP=Core` both pass on 1.12, which is what CI runs.

Stacked on SciML#1119 - it carries the `_lu_from_cacheval` fix this needs.

Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017rr42T1vFRRT8D7DMAmJzf
ChrisRackauckas added a commit that referenced this pull request Jul 27, 2026
`test/runtests.jl` includes `qa/allocations.jl` from two groups, and they do not
cover the same Julia versions:

    [QA]              versions = ["lts", "1"]
    [AppleAccelerate] versions = ["lts", "1", "pre"]

So AllocCheck ran on `pre` through a group that was never meant to carry it.

The file mixes two things with different portability. The direct-BLAS
refactorization proofs hold on every release - that contract is why the
AppleAccelerate group exists. The SupernodalLU sweep proof does not: above
`PANEL_BLAS_CUTOFF` the sweeps hand off to LinearAlgebra's `ldiv!`/`mul!`,
whose wrappers leave a `generic_trimatdiv!` dynamic dispatch plus boxed
`MulAddMul`/`SubArray` values that AllocCheck reports on 1.10 and 1.13. Only
the second one belongs behind QA's version set, so only it moves.

Measured, same two matrices as the testset:

    Julia 1.10.11  x86_64-Linux   18 static findings   runtime 0 B / 0 B
    Julia 1.12.6   x86_64-Linux    0 static findings   runtime 0 B / 0 B

18 findings on 1.10 x86_64-Linux is the same count #1119 measured on 1.10
aarch64-Darwin, and 1.12 is clean on both, so this is the stdlib and the Julia
version - not the platform, and not this package's code. Worth noting the
coverage is narrower than the toml suggests: only one QA job materialises
(`QA (julia 1)`), so the static proof runs on 1.12 alone.

The static proof keeps a version guard, since that is a limit of the prover.
`RUNTIME_MULTIRHS_ZERO` is dropped: it gated a *runtime* allocation assertion,
and on 1.11 that assertion fails because the multi-RHS path really does
allocate (6144 B, per #1119's table). Skipping it hides a regression rather
than a prover limitation, so it should fail there until the allocation is
fixed.

Verified: `qa/allocations.jl` completes on Julia 1.10, the version that was
failing; `GROUP=QA` and `GROUP=Core` both pass on 1.12, which is what CI runs.

Stacked on #1119 - it carries the `_lu_from_cacheval` fix this needs.



Claude-Session: https://claude.ai/code/session_017rr42T1vFRRT8D7DMAmJzf

Co-authored-by: ChrisRackauckas-Claude <accounts@chrisrackauckas.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants