Repository navigation
Solve a mass system on a non-contiguous view correctly - #32
Conversation
The LAPACK banded Cholesky solve behind BandedMass addresses its argument as contiguous memory, and the BandedMatrices wrapper does not check the stride, so mass_solve! into a stride-2 view returned wrong values and wrote into the parent entries the view skips. CirculantMass refused such a view through FFTW's stride check. Both operators now stage a non-contiguous argument through a buffer they own, which keeps the solve allocation-free. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The buffered path for a non-contiguous argument copied through copyto!, which truncates silently: a strided rhs one entry short on a CirculantMass, or a strided result one entry long on either operator, returned a result where the contiguous path throws. The staging copies are broadcasts now, which raise DimensionMismatch. Two assertions in the non-contiguous testset cover it. A comment line in CirculantMass is reflowed to the margin, with its added sentence moved after the sentence whose referent it had displaced. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test/quality/jet.jl analyses every function that a test checks with @allocated, at the argument types that test passes. The new testset checks mass_solve! into a stride-2 view, so JET now analyses that type on a banded and a circulant operator. The BandedMass docstring says that a non-contiguous argument goes through a buffer the operator owns. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #32 +/- ##
==========================================
- Coverage 91.42% 91.41% -0.02%
==========================================
Files 9 9
Lines 1178 1188 +10
==========================================
+ Hits 1077 1086 +9
- Misses 101 102 +1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
michakraus
left a comment
There was a problem hiding this comment.
JuliaDEC/SimpleSplines.jl PR #32: Solve a mass system on a non-contiguous view correctly
Verdict: request changes. (Posted as a comment review. The verdict is "request changes".)
The fix for the real-valued case is correct. I reproduced the defect and the fix. But the new BandedMass branch breaks a call that gave the correct answer before this PR: a complex non-contiguous result on a real operator now throws InexactError.
Findings
| # | severity | file:line | claim | evidence |
|---|---|---|---|---|
| 1 | bug | src/mass.jl:493-499 |
Regression. A non-contiguous result whose element type is not the operator's T now goes through op.buf::Vector{T}. A ComplexF64 stride-2 result on a real BandedMass worked before this PR and now throws. The cause: ldiv!(op.fact, y) reaches LAPACK pbtrs! only when eltype(y) == T. Any other element type takes the generic LinearAlgebra path, which handles a stride correctly. Through l2_projection! this also breaks the complex samples that src/quadrature.jl:411-413 documents as accepted, when the result is a view on a bounded basis. |
Probe at the PR head, Julia 1.13.1, a cubic Dirichlet basis with 34 cells. The pre-PR method body (copyto!(y, x); ldiv!(op.fact, y)) on a complex stride-2 y gives maxerr 4.564234742547372e-13, skipped entries changed = false. which returns ldiv!(C::Cholesky, B::AbstractVecOrMat) @ LinearAlgebra .../cholesky.jl:741. The PR head gives mass_solve! → throws InexactError: Float64(-1.4107270035180548 + 0.29255152095027787im). ldiv!(stride-2 complex y, opb, x) throws the same error. l2_projection!(stride-2 complex u, q, complex f) → throws InexactError: Float64(0.006251635923360947 - 0.012014747277459im). |
| 2 | quality | src/mass.jl:493 |
Proposed fix for #1. Use the buffer only where LAPACK is reached: if _contiguous(y) || eltype(y) !== T takes the direct path, and the else branch keeps the buffer (with op::BandedMass{T} and where {T} on the method). |
I ran this body as a local function at the PR head. Stride-2, negative-stride and non-strided (view(_, collect(1:N))) results give maxerr 7.1e-14 for Float64 and maxerr 3.06e-13 for ComplexF64. The aliased results give the same values, and the skipped parent entries are untouched = true in both element types. |
| 3 | quality | test/mass.jl:315-375 |
The new testset uses only Float64 arguments, so it cannot see #1. Add a ComplexF64 stride-2 result on the banded cases, compared against the dense solve. |
Read the testset: x = randn(N), and strided fills with 7.0. |
| 4 | quality | src/mass.jl:494 |
An adjacent pre-existing defect on a line that this PR moves. The PR body names it and leaves it. On the contiguous path, y === x || copyto!(y, x) accepts a right-hand side that is too short and solves on stale entries of y. The new strided branch on the next lines refuses the same input. The PR has made the two branches disagree on one input, so fix both in this change. A broadcast y .= x matches the new branch. |
Probe: mass_solve!(zeros(N), op, ones(N - 1)) → returned, no error. mass_solve!(view(zeros(2N), 1:2:2N), op, ones(N - 1)) → throws DimensionMismatch. |
| 5 | nit | src/mass.jl:160-161 |
"so one operator is not to be shared between threads" is too broad for BandedMass. The contiguous branch reads only op.fact, and only a non-contiguous result writes op.buf. CirculantMass writes op.buf on every solve, but its docstring (src/mass.jl:269-271) has no thread note. Only the comment at src/tensorproduct.jl:413 says so. |
Read src/mass.jl:492-517. |
| 6 | nit | src/mass.jl:450-451 |
The PR body names this one as pre-existing too: the mass_solve! docstring says "the two representations of MassOperator". There are four subtypes: FactorizedMass, BandedMass, CirculantMass and KroneckerMass. The docstring is in the function this PR changes. |
grep -n '<: MassOperator' src/ returns src/mass.jl:67, :165, :301 and src/tensorproduct.jl:337. |
Verified and fine
- I reproduced the defect on the pre-PR code paths, reached directly at the head. The banded stride-2 solve gives
maxerr = 2769.3694192057733, skipped entries changed = true. The circulant stride-2 right-hand side givesArgumentError: FFTW plan applied to wrong-strides array. - Allocations, measured in fresh processes with Julia 1.13.1,
--check-bounds=autoand--check-bounds=yes, minimum of 5 measurements.BandedMassandCirculantMassallocate 0 bytes each for these arguments: a contiguous one, a stride-2 result, a stride-2 right-hand side, an aliased stride-2 argument and a negative-stride result. - Non-strided views (
view(_, collect(1:N))) on both operators:maxerr 2.8e-13and3.8e-13against the dense solve.op \ view(X, 1:2:2N, :)onBandedMass:maxerr 4.5e-13. - A Float32
BandedMasswith a Float64 result has the same accuracy on both branches:7.0e-5contiguous and8.6e-5strided. TheCirculantMasselement-typeMethodErrors are on the contiguous path too, so this PR did not introduce them. - The full
Pkg.test()at the head passes on Julia 1.13.1. The "Mass operators" testset gives1089/1089, and JET gives12/12.opcin the newtest/quality/jet.jllines is defined at line 53. - JuliaFormatter (
sciml) reports the four changed.jlfiles as formatted. - The PR body has no closing keyword, and
closingIssuesReferencesis empty.
CI
All required checks pass. Branch protection and the main ruleset require the same 7 contexts: Julia {min,1} - {ubuntu,macOS,windows}-latest - default and Doctests - ubuntu-latest. Neither the classic protection nor the ruleset requires Downgrade - ubuntu-latest. That job has continue-on-error: true (.github/workflows/CI.yml:128).
The Downgrade failure is a resolver floor check, not a test failure, and it is not caused by this PR. The PR changes no Project.toml. The job fails in the same way on main at e6999c4 (run 37038019344), which reports the workflow as success:
Error: forcedeps check failed: FFTW resolved to 1.3.1 but lower bound is 1.0.0
Error: forcedeps check failed: ContinuumArrays resolved to 0.20.5 but lower bound is 0.18.0
Error: forcedeps check failed: BandedMatrices resolved to 1.7.6 but lower bound is 1.0.0
ERROR: LoadError: forcedeps check failed for .: Some packages did not resolve to their lower bounds.
The suite never runs at the [compat] floors. This PR depends on BandedMatrices behaviour, so the floor BandedMatrices = "1" stays untested.
Not checked
- The PR body says the new testset gives "6 pass, 8 fail, 7 error" on
main. I did not run the suite onmain. I reproduced the defect on the pre-PR method bodies instead. - Julia 1.11 locally. CI
minis green. - Thread safety beyond reading the code.
A non-contiguous result of another element type than the factor's, such as a
ComplexF64 stride-2 view on a real BandedMass, went through the Vector{T}
buffer and threw InexactError. LAPACK is reached only for StridedVecOrMat{T};
any other element type takes LinearAlgebra's generic Cholesky ldiv!, which
handles any stride, as it did before this branch. l2_projection! with complex
samples into a view reaches the same path. A complex strided case is added to
the testset.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review findings: resolutionFollow-up to the review above. One commit pushed: 8076939 "Keep a non-T strided result of a BandedMass off the real buffer".
CI at 8076939
No changelog change: the existing Unreleased entry already describes the final behaviour, and the regression in finding 1 was never released. |
Problem
mass_solve!on aBandedMass(every bounded basis) returns wrong values, with no error, when the result is a non-contiguous view such asview(w, 1:2:2m). It also writes into the parent entries that the view skips. The error depends on what the skipped entries hold. With 34 cells, a Dirichlet cubic basis and the skipped entries set to 7.0, the maximum error against a dense solve was about 2.8e3.ldiv!(y, op, x)andl2_projection!reach the same path.Cause.
BandedMasssolves withldiv!on a BandedMatrices banded Cholesky factor. For a strided vector that dispatches topbtrs!, which calls LAPACK?pbtrswithpointer(B)and does not check the stride ofB(it callschkstride1onAonly). LAPACK then reads and writesNcontiguous entries from the first element of the view. A negative stride givesinvalid argument #8 to LAPACK call.The sibling
CirculantMassfails loudly instead: an FFTW plan is bound to the strides it was planned for, so a strided result or right-hand side raisesFFTW plan applied to wrong-strides array.FactorizedMass(CHOLMOD) already handled any stride, because it solves into a temporary and copies.KroneckerMasscopies each fibre into contiguous buffers before the one-dimensional solve and was not affected.Fix
BandedMassandCirculantMasshold a preallocated real buffer of lengthN. A non-contiguous argument (stride != 1, or not aStridedVector) goes through that buffer. A contiguous one takes the same path as before. Both solves stay allocation-free for any stride, negative strides included. The staging copies are broadcasts, so a strided argument of the wrong length raisesDimensionMismatch, as the contiguous path does.A copy is the right fix here, not a mask: both LAPACK
?pbtrsand an FFTW plan can only address memory with the layout they were given. The BLAS triangular solves accept a stride, but for a negative stride the pointer Julia passes is the wrong end of the array, and a probe segfaulted.Test
New testset
a non-contiguous argument is solved, not misreadintest/mass.jl. It coversBandedMass(uniform Dirichlet, graded free),CirculantMass,FactorizedMass, and both periodickernel = :projectoperators. Each case checks a stride-2 result, a stride-2 right-hand side, an aliased stride-2 argument and a negative-stride result against a dense solve. It also checks that the skipped parent entries stay unchanged, and that the banded and circulant solves allocate nothing on the strided path.main: 6 pass, 8 fail, 7 error in that testset.test/mass.jlgives 1089/1089, and the fullPkg.test()passes locally (Julia 1.13.1).test/quality/jet.jlanalysesmass_solve!at the stride-2 result type on both operators: 0 reports.Pre-PR verification
Fixed
copyto!. That accepted a strided argument of the wrong length without an error, where the contiguous path throws. The staging copies are broadcasts, which raiseDimensionMismatch. Two@test_throwscover it.CirculantMassconstructor is reflowed to the margin.test/quality/jet.jlanalysesmass_solve!at the strided result type that the new@allocatedtest passes. TheBandedMassdocstring states that a non-contiguous argument goes through a buffer the operator owns.Pre-existing, not changed here
BandedMasson a contiguous result accepts a right-hand side that is too short (y === x || copyto!(y, x)inmass_solve!).mass_solve!docstring says "two representations" where there are three.pbtrs!is an upstream defect; this PR does not depend on a fix there.Checked and clean
code_typedonmass_solve!for both operators at 5 argument shapes each: concrete return types, no dynamic dispatch.test_piracies,fatou lintand JuliaFormatter are clean. The CHANGELOG entry is present.Not checked
mincovers it), a docs build,Float32operators on the strided path, and thread safety.🤖 Generated with Claude Code