Fix data race in BSpline and QuadraticSpline evaluation (rebase of #533)#550
Conversation
BSplineInterpolation, BSplineApprox, and QuadraticSpline (with the default
cache_parameters=false) kept a scratch buffer in their `sc` field and overwrote
it in place on every evaluation. Because a "read" (evaluation) mutated shared
state inside the object, evaluation was not reentrant: evaluating a single
shared interpolant from multiple threads raced on `A.sc` and silently returned
wrong values.
- BSpline{Interpolation,Approx} (interpolation_methods.jl, derivatives.jl):
allocate the spline-coefficient scratch per call instead of reusing `A.sc`.
This matches the existing ForwardDiff.Dual branch and makes eval reentrant.
- QuadraticSpline (parameter_caches.jl): compute the three nonzero degree-2
B-spline basis values in local variables instead of writing them into the
shared `A.sc` buffer. Keeps evaluation allocation-free (verified for scalar
and SVector data) while making it reentrant; also covers derivatives/integrals.
- Add thread-safety regression tests: a deterministic no-mutation-on-eval check
plus a concurrent stress test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A degree-1 B-spline goes through the same _interpolate method as higher degrees (dispatch is on type, not degree), so it was equally subject to the shared-buffer race; add it explicitly to document the coverage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Added a follow-up commit: Make BSpline evaluation allocation-free as well as thread-safe. The original #532 fix made BSpline eval reentrant by allocating a per-call scratch buffer ( Approach: a degree- To keep a single statically-allocation-free path (AllocCheck sees every branch reachable from the call operator, including extrapolation paths into
Allocations/call (length-99 scalar spline):
Verification (all run locally):
🤖 Generated with Claude Code |
The thread-safety fix for SciML#532 replaced the shared `sc` scratch field with a per-call `zeros(eltype(t), n)` buffer in BSpline{Interpolation,Approx} evaluation and differentiation. That made eval reentrant but reintroduced a heap allocation on every call (848 B for a length-99 scalar spline; QuadraticSpline was already allocation-free via its local-variable rewrite). A degree-`d` B-spline has only `d + 1` nonzero basis functions at any point, so a buffer sized to the full knot vector is unnecessary. `bspline_nonzero_coefficients` computes those `d + 1` values into a stack-allocated `MVector` (StaticArrays) and returns them as an `SVector` window plus the control-point offset. This is both allocation-free and reentrant — no shared or heap state per call. All four `_interpolate` and four `_derivative` BSpline methods use it. Because the guarantee requires a single statically-allocation-free path (AllocCheck sees every branch reachable from the call operator, including the extrapolation paths that dispatch to `_derivative`), the heap fallback is removed and the degree is bounded at construction (`d < 16`). The largest degree used anywhere in the repo is 3; degree ≥16 global B-splines are numerically degenerate. StaticArrays moves from a test-only dependency to a hard dependency. Allocations per call (length-99 scalar spline): value 848 B → 0 B, derivative 848 B → 0 B; BSplineApprox 128 B → 0 B. Array-valued eval still allocates its output array, which is inherent. Adds AllocCheck regression coverage for BSpline value and derivative evaluation to test/qa/alloc_tests.jl. Co-Authored-By: Chris Rackauckas <accounts@chrisrackauckas.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TpSbnpEmw2Z2z5rxvHWLGn
82c269e to
d60cf28
Compare
Please ignore until reviewed by @ChrisRackauckas.
Rebased version of #533 (original by @alecloudenback) onto current
master. That PR was 18 commits behind and conflicting; this branch resolves the conflicts and updates it for the new test layout. Opened from the bot fork because I lacked push access to update #533's head branch directly. Supersedes #533 — close whichever is not merged.Original change (#532 fix)
BSplineInterpolation,BSplineApprox, and uncachedQuadraticSplinekept a scratch buffer in theirscfield and overwrote it in place on every evaluation, so a "read" mutated shared state — evaluating one shared interpolant from multiple threads raced and silently returned wrong values.A.sc, making evaluation reentrant.QuadraticSpline: compute the three nonzero degree-2 B-spline basis values in local variables instead of writing to the shared buffer.Rebase conflict resolution
src/parameter_caches.jl: master's Fix QuadraticSpline edge-case crashes: n=2 BoundsError, Rational t starting at 0, duplicate knots #543 added alength(t) == 2edge-case guard while still using the sharedscbuffer; Fix data race in BSpline and QuadraticSpline evaluation #533 removed that buffer for thread safety. Merged both — kept the 2-point edge case and the reentrant local-variable basis evaluation.test/runtests.jl: master migrated toSciMLTesting's folder-basedrun_tests()(Use SciMLTesting v1.2 (folder-based run_tests) #547), so the manual@safetestsetlist is gone. Took master's version and moved the new test totest/Methods/thread_safety_tests.jlso it's auto-discovered under theMethodsgroup.Verification (run locally)
julia -t4(concurrent stress path exercised, not skipped).QuadraticSplineedge case works; cached vs uncached agree across the range.🤖 Generated with Claude Code