TriangularSolve-only routing for NotIPIV backsolves; fix pivot-free lu! with user ipiv; factorization dispatch audit - #116
Merged
ChrisRackauckas merged 5 commits intoAug 9, 2026
Conversation
Since ca26d78 (Jan 2023), lu!(A, ipiv, Val(false), ...) on Julia >= 1.8 returned the caller's ipiv inside the LU without ever writing it. Stdlib consumers of F.ipiv (LAPACK.getrs! via ldiv!, LinearAlgebra._ipiv_rows!) then read undefined memory: LinearSolve.jl's RFLUFactorization(pivot = Val(false)) segfaulted in dlaswp on every vector solve and threw BoundsError on matrix solves. Fill the supplied vector with the identity permutation; NotIPIV (RF's own pivot-free path) is unaffected. Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UGVN6qeNL2jGCtaYg1386X
ldiv!(::LU{T, <:StridedMatrix, <:NotIPIV}, b::StridedVector) handed the
vector straight to TriangularSolve.ldiv!, whose vector entry point is
native only up to n = 128 and defers to BLAS trsv above (and on
TriangularSolve <= 0.2.3 always fell through the LinearAlgebra catch-all).
Present a contiguous vector as an n-by-1 matrix (zero-copy reshape) so both
triangular legs stay on TriangularSolve's native matrix kernels at every
size; return B in its original shape. Also drop a dead square_view
binding in that method.
Add a which()-based dispatch audit that fails if any signature RF hands to
TriangularSolve.ldiv! resolves to the LinearAlgebra catch-all again, plus
correctness tests across the n = 128 cutoff, and bump to 0.2.29.
Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UGVN6qeNL2jGCtaYg1386X
The recursive lu! never calls BLAS/LAPACK for Float32/Float64: leaf factorizations run RF's own @turbo _generic_lufact!, Schur complements run @(t)turbo schur_complement!, and panel solves dispatch to TriangularSolve's native kernels (verified by walking the optimized IR of all three for the exact PtrArray panel types — no gemm/getrf/trsm/trsv/trtrs/syrk/ger anywhere). The BLAS binding imported in lu.jl was never used; remove it, so the module no longer imports any BLAS entry point. New testset enforces the factorization side of the routing table: (a) the exact panel-view types the Float32/Float64 recursion constructs must resolve to native TriangularSolve kernel methods; (b) a whole-suite sweep asserts no TriangularSolve catch-all specialization exists with a Float32/Float64 triangular argument — any silent LinearAlgebra/BLAS fallback of a real-eltype solve anywhere in the test run fails it; (c) characterizes the known gap: complex panel solves resolve to the catch-all (LinearAlgebra -> LAPACK trtrs!), since TriangularSolve has no complex kernels. Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UGVN6qeNL2jGCtaYg1386X
The 1e-10 absolute bound sat inside the legitimate rounding band for the n≈800 Wilkinson solves (c*n*eps*normA*normx ≈ 1e-9) and depended on suite-order RNG position for its b draws, while the butterfly transforms themselves vary per machine (VectorizedRNG streams follow SIMD width): CI observed a spurious 7.6e-10 on one runner while 550 draws on Zen 2 stay below 2.3e-11 under both the old (TS vector entry / trsv) and new (n-by-1 reshape) backsolve routes, with same-order worst cases — rounding, not breakage. Seed the testset so earlier testsets cannot shift its draws, and bound the relative residual at 1e-8, far above rounding and far below any genuine routing/pivoting failure (>=1e-5). Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UGVN6qeNL2jGCtaYg1386X
…r kernels TriangularSolve 0.2.5 (JuliaSIMD/TriangularSolve.jl#48) replaced its vector-entry BLAS deferral (native <= 128, trsv above) with a rank-4 pure-Julia sweep: never-BLAS and faster than trsv/getrs! at every size (measured 0.33-0.79x of getrs! and ~2x faster than the n-by-1-reshape workaround this branch previously used, 1 thread, Zen 2; allocation-free). Drop the reshape helper, call the vector entry directly, and raise the TriangularSolve compat floor to 0.2.5 so the vector legs can never silently defer to BLAS on older TriangularSolve. The dispatch audit now asserts the vector signatures resolve to the native vector kernel methods. Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UGVN6qeNL2jGCtaYg1386X
ChrisRackauckas
marked this pull request as ready for review
August 9, 2026 03:36
ChrisRackauckas
merged commit Aug 9, 2026
e7cb526
into
JuliaLinearAlgebra:master
20 of 21 checks passed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Policy
Per the routing directive for RecursiveFactorization: both the backsolves consuming an RF factorization and the
lu!factorization itself should run on TriangularSolve's native kernels / RF's own@turbokernels — vector and matrix right-hand sides, with and without pivoting — and never silently fall back to a BLAS/LAPACK kernel where a native path exists (Float32/Float64, strided). This PR enforces that for everything RecursiveFactorization itself controls, audits the factorization path end to end, and fixes an undefined-memory crash found during the audit.What changed
lu!(A, ipiv, Val(false), ...)now fills a user-suppliedipivwith the identity (src/lu.jl). Since ca26d78 (2023-01), the pivot-free algorithm on Julia ≥ 1.8 returned the caller'sipivinside theLUwithout ever writing it. Any consumer ofF.ipiv—LAPACK.getrs!via the stdlibldiv!, orLinearAlgebra._ipiv_rows!— then read undefined memory. This is how LinearSolve callslu!, and it segfaulted indlaswp(companion LinearSolve PR below).NotIPIV(the path RF's ownlu!(A, Val(false))takes) is unaffected.trsvabove. TriangularSolve 0.2.5 (Replace the vector-RHS BLAS deferral with a rank-4 pure-Julia sweep (never-BLAS vectors) JuliaSIMD/TriangularSolve.jl#48, merged and registered while this PR was in flight) replaced that deferral with a rank-4 pure-Julia sweep — never-BLAS and faster thantrsvat all sizes — so theNotIPIVldiv!now simply calls the vector entry directly (an interim revision of this branch routed vectors as n×1 reshapes onto the matrix kernels; that workaround is gone — measured ~2× slower than 0.2.5's vector kernels). Also removes a deadsquare_viewline in that method and returnsBexplicitly.BLASimport fromsrc/lu.jl(never used — the module now imports no BLAS entry point at all), and added a dispatch-audit testset for the factorization's panel solves (details below). No behavior change in the factorization; items 1–2 are the only behavior changes in the PR.test/runtests.jl). The first CI round failed only the julia-pre (1.13.0-rc1) ubuntu lane, in the pre-existing🦋testset:norm(A*out .- b) = 7.6e-10vs an absolute1e-10bound (https://github.com/JuliaLinearAlgebra/RecursiveFactorization.jl/actions/runs/31273560107/job/93143409318). Investigation: the same suite passes on 1.13.0-rc1 locally; a 550-draw sweep (n = 790–810, 50 seeds each) puts the worst residual at 2.2e-11 for the new backsolve route vs 1.1e-11 for the old one — same order, both far under the bound — while the backward-stable band for these n≈800 solves is ~1e-9 absolute, i.e. the old1e-10sat inside legitimate rounding. The test's inputs are also hardware-dependent (VectorizedRNG streams vary with SIMD width) and itsbdraws depended on suite-order RNG position, which the new testsets shifted. Fix: seed the testset locally and bound the relative residual at1e-8(≥20× above any observed rounding value, ≥3 orders below genuine breakage), with the justification in a comment.Backsolve truth table for
ldiv!on RF factorizations (direct use)getrs!(BLAS)getrs!(BLAS)NotIPIV), vectortrsvaboveNotIPIV), matrixEvidence (live
whichchains on Julia 1.12.4, RecursiveFactorization 0.2.28→this branch, TriangularSolve 0.2.5): the NoPivot entries resolve toRecursiveFactorization src/lu.jland then TriangularSolve's native kernels — matrix legs atTriangularSolve.jl:691/818, vector legs at the new vector methods (TriangularSolve.jl:1083, both 2- and 3-arg) — never theLinearAlgebra.ldiv!catch-all. An IR walk of the 0.2.5 vector kernel reaches no BLAS/LAPACK symbol, and a solve is allocation-free.Not in scope:
ldiv!on a pivoted RFlu!result dispatches to LinearAlgebra's own method (LU{T, <:StridedMatrix, Vector{BlasInt}}→getrs!); RF cannot own that method without type piracy (every type in the signature belongs to Base/LinearAlgebra). LinearSolve routes the pivoted case through TriangularSolve on its side (companion PR below).Factorization truth table for
lu!(audited, dispatch-enforced; unchanged behavior)@turbo _generic_lufact!leaves +@(t)turbo schur_complement!+ native TS panelldiv!(TriangularSolve.jl:691threaded /:705unthreaded for the exactPtrArrayview types) + pure-Juliaapply_permutation!_generic_lufact!@turboright-looking LUlu!/luwith minmn < 10LinearAlgebra.generic_lufact!lu!)ldiv!→ TS catch-all (:987) →LinearAlgebra.ldiv!→LAPACK.trtrs!_generic_lufact!(fallback loop)BigFloat,Matrix{Real}, …) or non-strided_generic_lufact!Evidence, all on Julia 1.12.4 (audit scripts:
fact_probe.jl):grep -rn "getrf\|LAPACK\|BLAS\." src/matches nothing but comments; theBLASimport was vestigial (removed here).whichresolves the exact recursion panel types (SquarePtrMatrix/PtrArrayviews) to native TS kernel methods for Float32/Float64, and to the catch-all for complex.lu!over Float64/Float32 × {pivot, NoPivot} × {threaded, not} × {n=30, 300} plus a fat 200×300 and the tiny minmn<10 branch creates zero TriangularSolve catch-all specializations with real eltype — none exist in the whole session (Base.specializationssweep). The complex runs create exactly the complex panel-signature specializations.:invokeedges of the optimized IR (depth 6) forTS.ldiv!on the panel types,schur_complement!, RF_generic_lufact!, and stdlibgeneric_lufact!finds nogemm/gemv/getrf/trsm/trsv/trtrs/syrk/geranywhere. The same walk onLinearAlgebra.ldiv!at the complex panel types hitsLAPACK.trtrs!at depth 1 — that is the one BLAS/LAPACK dependence left in the factorization, and it is a TriangularSolve capability gap (no complex kernels), not an RF routing choice. Left unchanged and characterized in the tests; see proposal item 4.Crash fix: failing before / passing after
Reproduction (
rf_ipiv_repro.jl): poison an undefipiv, factor withVal(false), consumeF.ipivvia the stdlibldiv!:Before (registered RecursiveFactorization v0.2.28):
(and from LinearSolve, where the ipiv is genuinely undef rather than poisoned,
solve(prob, RFLUFactorization(pivot=Val(false)))dies withsignal 11 (Segmentation fault) … dlaswp_plus … dgetrs_N_single.)After (this branch):
The new
"NoPivot lu! with a user-supplied ipiv leaves valid pivots"testset encodes exactly this.Never-fallback enforcement
test/runtests.jlnow contains three dispatch audits:"NotIPIV backsolves stay on TriangularSolve's native kernels": resolves, viawhich, every signature theNotIPIVldiv!hands toTriangularSolve.ldiv!and asserts the resolved method is a TriangularSolve native kernel and not identical to the catch-all method object. A correctness testset also pins vector/matrix results at n = 8…300, i.e. across TriangularSolve's n=128 vector cutoff."Factorization panel solves stay on TriangularSolve"(runs last): (a) the exactPtrArraypanel-view types the Float32/Float64 recursion constructs must resolve to native TS kernels, threaded and not; (b) a whole-suite specialization sweep — after every factorization and backsolve in the test run, no TriangularSolve catch-all specialization may exist whose triangular argument has Float32/Float64 eltype, so any real-eltype solve anywhere that silently fell back to LinearAlgebra/BLAS fails the suite; (c) a characterization of the complex gap: complex panel signatures resolve to the catch-all (flips, and should be updated, if TriangularSolve ever gains complex kernels).If a future TriangularSolve or RF restructuring reintroduces a silent BLAS fallback on real eltypes, these fail.
Performance
The factorization commit is behavior-neutral (import removal + tests only). For the backsolve: with TriangularSolve 0.2.5 the vector legs are faster than BLAS at every size — the earlier judgment call (accepting 2.3–2.6× at n = 512–1000 for never-BLAS purity, measured against the reshape workaround) no longer exists. Single-vector backsolve, interleaved min-times, 25×10 reps, 1 thread, Julia 1.12.4, Zen 2 (
bench_ts5.jl;getrs!= pivoted stdlib baseline, reshape = this branch's interim route):0 bytes allocated per solve (n=300 spot check). Never-BLAS and fastest option at every measured size.
Butterfly LU: BLAS census (findings only — nothing changed here)
src/butterflylu.jlstill uses BLAS per solve, verified by the same IR walk:mul!(tmp, U', b)(line 50) andmul!(b, V, tmp)(line 52) in🦋solve!— dense BLASgemvapplications of the U/V butterfly transforms, once each per solve. (Mirrored in LinearSolve'sButterflyFactorizationsolve!.)materializeUV'smul!(U, Bu2, Bu1)/mul!(V, Bv2, Bv1)(lines 176–177) —SparseBandedMatrixoperands are not strided, so these go through stdlib generic matmul: not BLAS, and setup-only (once per workspace).ldiv!(F, tmp, thread)route TS-catch-all →LinearAlgebra.ldiv!→ RF'sNotIPIVmethod → TS native kernels (two dispatch hops, but native; covered by the NotIPIV audit).Replacing the per-solve
gemvs would need either a TriangularSolve/LoopVectorization dense-gemvkernel or restructuring the transform application; that is a design decision, not a routing fix, so it is left for review.Verification
All with registered TriangularSolve 0.2.5 (fresh resolves), full suite
julia --project=. -e 'using Pkg; Pkg.test()':Test LU factorization | 3120 pass,NoPivot lu! with a user-supplied ipiv | 6 pass,NotIPIV backsolves stay on TriangularSolve | 38 pass,NotIPIV ldiv! correctness | 24 pass,🦋 | 21 pass,Factorization panel solves stay on TriangularSolve | 31 pass(3240 total).JULIA_NUM_THREADS=3, Julia 1.12.4 (threaded@batch/@tturbopaths): exit 0.typoson the diff: clean (thesizhits inruntests.jlare pre-existing lines outside this diff).evaluate(Invalidations) job is red on this PR and on master since 2025-10-08 — an upstream SnoopCompile crash on Julia 1.12 (Core.Bindinginreport_invalidationsJuliaDebug/SnoopCompile.jl#465), RF-independent. Separate mechanical fix: Pin the Invalidations workflow to Julia 1.11 (SnoopCompile #465 crashes on 1.12) #115.Judgment calls a reviewer should check
trtrs!— TriangularSolve has no complex kernels, so "TriangularSolve's path" does not exist there; rerouting complex to_generic_lufact!would be never-BLAS but a large regression at big n. Characterized in tests instead of changed; proposal item 4 is the structural fix.1e-10→ relative1e-8+ local seed) — loosening a bound is normally suspect; the distribution evidence and the backward-stability arithmetic above are the case that1e-10absolute was wrong-in-principle for n≈800. Push back if a tighter, still-principled bound is preferred (e.g.c·n·eps·‖b‖with measuredc).TriangularSolve follow-up proposal (not implemented here, deliberately not a PR)
ldiv_native!or similar) with no catch-all, so never-fallback callers getMethodErrorinstead of silent BLAS.has_native_kernel(::Type, ::Type)trait maintained next to the kernels, replacing downstreamwhich-based audits.Blocked single-RHS kernel to close the n ≥ 512 gap— delivered upstream while this PR was in flight: Replace the vector-RHS BLAS deferral with a rank-4 pure-Julia sweep (never-BLAS vectors) JuliaSIMD/TriangularSolve.jl#48 (registered as 0.2.5) removedVECTOR_RHS_CUTOFFand the BLAS deferral outright; this PR now consumes it.Companion LinearSolve PR: SciML/LinearSolve.jl#1171
🤖 Generated with Claude Code