Skip to content

Let a compiled expression be called from more than one thread (#637) - #698

Merged
Happypig375 merged 1 commit into
asc-community:masterfrom
Rafael-SOWNet:fix/parallel-compilation
Aug 4, 2026
Merged

Let a compiled expression be called from more than one thread (#637)#698
Happypig375 merged 1 commit into
asc-community:masterfrom
Rafael-SOWNet:fix/parallel-compilation

Conversation

@Rafael-SOWNet

Copy link
Copy Markdown
Collaborator

Fixes #637.

Reproduced, and it is worse than reported

FastExpression kept its working stack and its subexpression cache as instance fields:

private readonly Stack<System.Numerics.Complex> stack;
private readonly System.Numerics.Complex[] cache;

so two threads calling one compiled expression interleave their pushes and pops. Sixteen threads, 400 000 calls of a single expression:

outcome count
AngouriBugException: Unused values remain in the stack 399 328
InvalidOperationException: Stack empty. 418
a wrong number, no exception 1
correct 253

The wrong number is the part worth naming. It returned -0.483 where the answer is 14884.07. The report only saw the exception, and of the two that is the kinder failure.

The damage was also permanent. The count check throws before anything is popped, so one racing call left its leftovers on the stack and every later call tripped over them — including single-threaded ones afterwards:

var f = "x ^ 2 + 1".ToEntity().Compile("x");
Parallel.For(0, 20_000, i => { try { f.Call(2); } catch { } });
f.Call(3);   // AngouriBugException, on one thread, long after

Compiling in parallel — what the issue title says — was never broken. 2000 independent compilations across 16 threads: no failures, before or after. It is calling that was not safe.

The fix

Both buffers are now per thread rather than per expression, held in one small object so the thread-static lookup happens once per call.

Per thread and not per call because allocating nothing per call is the point of compiling. Neither buffer carries anything between calls:

  • the stack is emptied on the way in — which is also what cures the permanent poisoning;
  • a cache slot is only ever read by the same call that wrote it. That is how the compiler emits them: SAVE_CACHE at the definition of a repeated subexpression, LOAD_CACHE at each later use, always in that order (IntoFE/Compiler.cs, InnerCompile).

It is not slower

Best of five runs each, single-threaded, warmed:

expression before after
x^2 + 3x + 1 44.6 ns/call 43.6 ns/call
sin(u)cos(u) + u^2 + sin(u), u = x^2+1 123.4 ns/call 118.8 ns/call

(My first measurement was a single unwarmed run and showed a 33% regression. It was noise; these are best-of-five and the difference is within it either way. Recording the wrong first number here because it is the sort of thing worth not believing.)

Tests

Sources/Tests/UnitTests/Common/ParallelCompiledCallTest.cs, 4 cases. 3 fail without the source change (verified by reverting it in place):

  • one expression called from 16 threads, checking the value and not merely that nothing threw
  • the same for an expression with a repeated part, so the cache instructions are exercised too
  • that a racing call does not poison the expression for later single-threaded use
  • parallel compilation, which passed before and is kept so it stays passing

Full suite: Failed: 0, Passed: 4013, Skipped: 14, Total: 4027.

Not covered

This makes one compiled expression safe to call concurrently. It does not make MathS.Settings or the rest of the library's per-thread state safe to share, and it does not touch the Linq-compiled path, which builds a Func<...> and has no such state to begin with.

🤖 Generated with Claude Code

…mmunity#637)

FastExpression kept its working stack and its subexpression cache as
instance fields, so two threads calling one compiled expression interleaved
their pushes and pops. The count check at the end then found a stack it did
not leave, and threw.

Measured with sixteen threads over 400000 calls of one expression: 399328
threw "Unused values remain in the stack", 418 threw "Stack empty", and one
returned -0.483 where the answer is 14884.07. The wrong number is the part
worth naming -- the report only saw the exception, and a wrong number is
the worse of the two.

The damage was also permanent. The count check throws before anything is
popped, so a single racing call left its leftovers on the stack and every
later call failed on them too, on one thread or on many.

Both buffers are now per thread rather than per expression, in one object
so that the lookup happens once. Per thread and not per call because
allocating nothing per call is the point of compiling. Neither buffer
carries anything between calls: the stack is emptied on the way in, and a
cache slot is only ever read by the same call that wrote it, which is how
the compiler emits them -- SAVE_CACHE at the definition, LOAD_CACHE at each
later use.

Nothing is slower for it. Best of five runs, single-threaded:

    x^2 + 3x + 1                              44.6 -> 43.6 ns/call
    sin(u)cos(u) + u^2 + sin(u), u = x^2+1   123.4 -> 118.8 ns/call

Compiling in parallel, which is what the issue title says, was never
broken; there is a test for it now so that it stays that way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 80.57%. Comparing base (90c00a8) to head (e0a4eb9).
⚠️ Report is 58 commits behind head on master.
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.

Additional details and impacted files
@@            Coverage Diff             @@
##           master     #698      +/-   ##
==========================================
- Coverage   80.99%   80.57%   -0.42%     
==========================================
  Files         155      156       +1     
  Lines       13687    12924     -763     
  Branches     1957     2124     +167     
==========================================
- Hits        11086    10414     -672     
+ Misses       1990     1906      -84     
+ Partials      611      604       -7     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@Rafael-SOWNet

Copy link
Copy Markdown
Collaborator Author

CI, run on my fork across the full matrix, since the run here is held at action_required:

https://github.com/Rafael-SOWNet/AngouriMath/actions/runs/30930183903

job result tests
Test (windows-latest) success Failed: 0, Passed: 4013, Skipped: 14, Total: 4027
Test (ubuntu-latest) success Failed: 0, Passed: 4013, Skipped: 14, Total: 4027
Test (macos-latest) success Failed: 0, Passed: 4013, Skipped: 14, Total: 4027

Worth having all three here: the new tests run 16 threads over 300 000 calls, and thread scheduling is exactly the sort of thing that differs by platform. The reporter is on aarch64 Linux, which none of these are, but the defect is unconditional rather than a timing window — the state was shared, so any interleaving at all corrupts it.

The branch it ran on, ci-check/parallel, is this PR's head plus one commit that only touches .github/workflows/CSharpTest.yml, so the run can be triggered at all. Everything under Sources/ is byte-identical to this PR:

$ git diff --stat fix/parallel-compilation ci-check/parallel
 .github/workflows/CSharpTest.yml | 1 +
 1 file changed, 1 insertion(+)

@Happypig375
Happypig375 merged commit b845b05 into asc-community:master Aug 4, 2026
24 checks passed
@Rafael-SOWNet
Rafael-SOWNet deleted the fix/parallel-compilation branch August 4, 2026 20:49
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.

Parallel compilation can cause stack corruption in Expression compilation

3 participants