Matrix operations answer wrongly when called from more than one thread - #1230
Merged
Merged
Conversation
MathS.Multithreading documents concurrent use as supported, and matrix operations do not honour it. The cause is not in this repository: GenericTensor 1.0.4 keeps two process-wide mutable statics, reported upstream as asc-community/GenericTensor#40 with a fix in asc-community/GenericTensor#41. This is what stands in until a release carrying that fix exists. Measured on c2c8eb8, forty 4x4 matrices with non-polynomial entries, each computed once sequentially and then rebuilt and recomputed under Parallel.For: Determinant 38 of 40 disagree Inverse 18 of 40 disagree Adjugate 11 of 40 disagree m + m, m - m, PointwiseMultiplication throws, on a corrupted Dictionary Nothing about the first three looks wrong. They are well-formed entities of the right shape carrying another computation's values. The two statics need opposite treatments, because only one can be closed without a lock. The scratch-matrix pool behind DeterminantLaplace and Adjoint hands every caller the same tensor for a given size and the caller writes into it. There is nothing to warm and no way to avoid it from out here, so those three call sites take a lock. That serialises determinants and inverses of matrices the polynomial elimination declines -- PolynomialDeterminant.Of runs first and never reaches the pool, which is why the entries in the test are sines and cosines, and why a polynomial matrix was not affected at all. The compiled-operation cache behind the elementwise operators is an unsynchronised Dictionary, so it is only unsafe while being filled; once an entry is there the reads are pure. That one needs no lock, because the set of entries this library can ever ask for is fixed: Entity.Matrix rejects any tensor that is not rank 2, and every call here uses the default single-threaded mode, so the only reachable keys are addition, subtraction and multiplication at rank 2. Filling all three once under Lazy leaves the dictionary read-only, at no steady-state cost. Answers are unchanged, so there is no BREAKING-CHANGES entry: a single-threaded caller computed the same values before and computes them now. The tests rebuild their matrices inside each pass, which is load-bearing rather than tidiness. Determinant, Inverse and Adjugate are cached lazy properties, so reusing the instances lets the parallel pass read what the sequential pass already computed -- a green test that ran the operation once. The elementwise test has no sequential pass at all for the mirror-image reason: a warm-up populates every cache key before the threads start and passes against the unguarded code. Full suite green: 8499 and 1485. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012sonx8iAspMiwRwokT1Ura
Rafael-SOWNet
added a commit
that referenced
this pull request
Sep 10, 2026
…cause one caller had skipped it (#1246) FractionFreeDeterminantTest.EliminationAgreesWithLaplaceWhereverBothApply fails on master whenever it runs beside MatrixConcurrencyTest, and it fails by reporting the determinant of a matrix of integers and a, b, c as containing sin(a) -- entries that belong to the other test's matrices. The guard added in #1230 covers GenericTensor's process-wide scratch matrix at all three of the library's call sites. It did not cover this one, which reaches DeterminantLaplace directly in order to compare Laplace against the elimination, and one unguarded caller is enough: it corrupts every guarded caller and reads their minors back. Taking the lock in the test would fix this instance. Making the lock private and the three pool operations -- DeterminantLaplace, Adjoint, InvertMatrix -- the guard's whole surface fixes the shape, since a caller can then no longer reach GenericTensor without it. That is what this does. Adjoint returns the entity rather than the tensor for the same reason. The lock has to cover reading the elements out, and Entity.Matrix's constructor is where they are actually read, so a GenTensor-returning signature would put that read on the far side of the lock. Reproduction, on master, this machine: dotnet test --filter "FullyQualifiedName~MatrixConcurrencyTest|FullyQualifiedName~FractionFreeDeterminantTest" Failed: 1 (13 of 300 determinants disagree) and with this change, three runs of the same command, Failed: 0 each time. Suites: UnitTests 8514 and 1508, FSharpWrapperUnitTests 134, InteractiveWrapperUnitTests 18, TerminalUnitTests 41. #1219 Claude-Session: https://claude.ai/code/session_012sonx8iAspMiwRwokT1Ura Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
Fixes #1219.
MathS.Multithreadingdocuments concurrent use as supported, and matrix operations do not honour it.The cause is not in this repository. GenericTensor 1.0.4 keeps two process-wide mutable statics. I have reported it as GenericTensor#40 with a fix in GenericTensor#41. This PR is what stands in until a release carrying that fix exists, and
GenTensorGuardsays in its own doc comment that it should be deleted when one does.Measured
Forty 4×4 matrices with non-polynomial entries, on
c2c8eb83, each computed once sequentially and then rebuilt and recomputed underParallel.For:DeterminantInverseAdjugatem + m,m - m,PointwiseMultiplicationDictionaryNothing about the first three looks wrong. They are well-formed entities of the right shape carrying another computation's values.
Two statics, opposite treatments
Only one of them can be closed without a lock, which is why this is not a single uniform guard.
The scratch-matrix pool behind
DeterminantLaplaceandAdjointhands every caller the same tensor for a given size, and the caller writes into it. There is nothing to warm and no way to sidestep it from out here, so those three call sites take a lock. It serialises determinants and inverses of matrices thatPolynomialDeterminant.Ofdeclines — the polynomial elimination runs first and never reaches the pool, which is why the entries in the test are sines and cosines, and why a polynomial matrix was never affected.The compiled-operation cache behind the elementwise operators is an unsynchronised
Dictionary, so it is unsafe only while it is being filled; once an entry is there the reads are pure. That one takes no lock, because the set of entries this library can ever ask for is fixed and tiny:Entity.Matrixrejects any tensor that is not rank 2, and every call site here uses the default single-threaded mode, so the only reachable keys are addition, subtraction and multiplication at rank 2. Filling all three once underLazyleaves the dictionary read-only from then on, at no steady-state cost.Answers are unchanged
No
BREAKING-CHANGES.mdentry: a single-threaded caller computed these values before and computes the same ones now. The change is that a concurrent caller now does too.On the tests
Both halves have a trap in them and I walked into each one first.
The determinant tests rebuild their matrices inside every pass.
Determinant,InverseandAdjugateare cached lazy properties, so reusing the instances lets the parallel pass read what the sequential pass already computed — the test is green because the operation ran once.The elementwise test has no sequential pass at all, for the mirror-image reason: computing the expected values first populates every cache key before the threads start, and the test then passes against the unguarded code.
All four fail on
c2c8eb83and pass here. Full suite green: 8499 and 1485.🤖 Generated with Claude Code
https://claude.ai/code/session_012sonx8iAspMiwRwokT1Ura