Skip to content

Matrix operations answer wrongly when called from more than one thread - #1230

Merged
Rafael-SOWNet merged 1 commit into
masterfrom
gentensor-guard
Sep 9, 2026
Merged

Rafael-SOWNet merged 1 commit into
masterfrom
gentensor-guard

Conversation

@Rafael-SOWNet

Copy link
Copy Markdown
Member

Fixes #1219.

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. 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 GenTensorGuard says 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 under Parallel.For:

disagreements
Determinant 38 of 40
Inverse 18 of 40
Adjugate 11 of 40
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.

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 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 sidestep it from out here, so those three call sites take a lock. It serialises determinants and inverses of matrices that PolynomialDeterminant.Of declines — 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.Matrix rejects 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 under Lazy leaves the dictionary read-only from then on, at no steady-state cost.

Answers are unchanged

No BREAKING-CHANGES.md entry: 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, Inverse and Adjugate are 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 c2c8eb83 and pass here. Full suite green: 8499 and 1485.

🤖 Generated with Claude Code

https://claude.ai/code/session_012sonx8iAspMiwRwokT1Ura

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
Rafael-SOWNet merged commit 6a97c07 into master Sep 9, 2026
31 checks passed
@Rafael-SOWNet
Rafael-SOWNet deleted the gentensor-guard branch September 9, 2026 11:48
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>
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.

A Laplace determinant came back with another expression's integers in it, once, under a parallel test run

1 participant