Repository navigation
Evaluation writes iguesser.idx_prev, so threads sharing an interpolant contend on it #618
Description
Evaluating an interpolant stores the interval it found in A.iguesser.idx_prev, on every lookup:
# src/interpolation_utils.jl, get_idx(A, t, iguess::Guesser; ...), on master as of 2026-10-02
hint = iguess(t)
raw = _dispatch_search(A, tvec, t, hint, side)
idx = clamp(raw + idx_shift, lb, ub)
iguess.idx_prev[] = idx
# src/interpolation_methods.jl, _linear_uniform_interpolate (LinearInterpolation on uniform Vector knots)
A.iguesser.idx_prev[] = idx0 + 1This is separate from #532 / #550, which fixed wrong values from the shared sc buffer. Here the
results stay correct: the stored index only seeds the search, and a guess that another thread
overwrote just costs a few more steps. Two consequences, though:
- It is an unsynchronized write to shared state from every evaluating thread. That is a data race
in Julia's memory model, even though the values stay correct in practice. It also contradicts
the invariant stated intest/Methods/thread_safety_tests.jl("Evaluation (and differentiation)
must be reentrant: it must not write to any state stored in the interpolant"). That test checks
scbut notiguesser. - Threads sharing one interpolant keep writing the same
Ref, which costs throughput. With random
query points, a shared interpolant was 12–21% slower than a copy per thread, with identical
results.
Where nothing reads idx_prev, the write buys nothing:
get_idxwrites even wheniguesser.linear_lookupistrue, and the hint is then computed fromt.LinearInterpolationon uniformVectorknots finds the cell in closed form and writes it anyway.
MWE (only DataInterpolations; run with julia -t 8)
using DataInterpolations, Random
println("DataInterpolations ", pkgversion(DataInterpolations), ", Julia ", VERSION, ", ", Threads.nthreads(), " threads")
# 1. Evaluating an interpolant writes to state stored in it.
t = [0.5, 1.0, 2.0, 3.0, 5.0, 7.0, 10.0, 12.0, 15.0, 20.0, 25.0, 30.0] # non-uniform knots
u = 0.03 .+ 0.01 .* (1 .- exp.(-t ./ 5))
A = LinearInterpolation(u, t; cache_parameters = true)
before = A.iguesser.idx_prev[]
A(22.0)
println("idx_prev before = ", before, ", after A(22.0) = ", A.iguesser.idx_prev[])
# LinearInterpolation on uniform knots finds the cell in closed form and writes it too, though nothing reads it there.
B = LinearInterpolation(u, collect(range(0.5, 30.0; length = length(t))); cache_parameters = true)
before = B.iguesser.idx_prev[]
B(22.0)
println("uniform grid: linear_lookup = ", B.iguesser.linear_lookup, ", idx_prev before = ", before, ", after = ", B.iguesser.idx_prev[])
# 2. Threads sharing one interpolant contend on that write. Same work per thread, either one
# shared interpolant or a copy per thread. Results are identical; only the timing differs.
const N = 2_000_000
xs = [0.5 .+ 29.5 .* rand(Xoshiro(k), N) for k in 1:Threads.nthreads()] # inside [first(t), last(t)]
work(A, x) = (s = 0.0; for xi in x; s += A(xi); end; s)
function run(interps)
out = zeros(Threads.nthreads())
Threads.@threads :static for k in 1:Threads.nthreads()
out[k] = work(interps[k], xs[k])
end
return out
end
serial = [work(A, xs[k]) for k in 1:Threads.nthreads()]
shared = fill(A, Threads.nthreads())
copies = [deepcopy(A) for _ in 1:Threads.nthreads()]
run(shared); run(copies) # compile
for _ in 1:3
ts = @elapsed r1 = run(shared)
tc = @elapsed r2 = run(copies)
println("shared interpolant: ", round(1e3 * ts; digits = 1), " ms per-thread copies: ", round(1e3 * tc; digits = 1),
" ms results equal to serial: ", r1 == serial && r2 == serial)
endOutput (Apple M4 Max, Julia 1.12.5, DataInterpolations 10.1.3, FindFirstFunctions 3.4.0):
DataInterpolations 10.1.3, Julia 1.12.5, 8 threads
idx_prev before = 1, after A(22.0) = 10
uniform grid: linear_lookup = true, idx_prev before = 1, after = 9
shared interpolant: 52.3 ms per-thread copies: 46.8 ms results equal to serial: true
shared interpolant: 51.9 ms per-thread copies: 43.0 ms results equal to serial: true
shared interpolant: 52.1 ms per-thread copies: 45.2 ms results equal to serial: true
With -t 1 the two cases take the same time (40–42 ms).
What the write buys
On knots that don't look linear, the stored index is the hint, and for sorted queries on large grids
it's worth a lot. I timed master against a branch with both writes removed (script below), using
1M queries per case and cubed, clustered knots, where linear_lookup is false. Times are the
minimum of two processes × five runs, master → no write:
| Knots | Method | Sorted, 1 thread | Random, 1 thread | Random, 8 threads, shared | Random, 8 threads, copies |
|---|---|---|---|---|---|
| 12 | Linear | 9.5 → 10.4 ms (+10%) | −7% | −27% | −9% |
| 12 | CubicSpline | 21.4 → 21.1 ms (−1%) | −6% | −44% | −18% |
| 100 | Linear | 16.4 → 22.4 ms (+37%) | −15% | −31% | −16% |
| 100 | CubicSpline | 23.4 → 36.0 ms (+54%) | −7% | −20% | −8% |
| 1,000 | Linear | 16.2 → 37.6 ms (+133%) | −19% | −36% | −22% |
| 1,000 | CubicSpline | 23.4 → 54.4 ms (+132%) | −8% | −13% | −6% |
| 100,000 | Linear | 16.7 → 74.2 ms (+345%) | −27% | −34% | −30% |
| 100,000 | CubicSpline | 27.5 → 100.8 ms (+267%) | −9% | −21% | −16% |
So simply dropping the write isn't the answer: sorted queries, presumably the reason #273 added
idx_prev, get up to 4.5× slower. The write costs random queries, even single-threaded, and more
when threads share one interpolant. Writing only when the index changes doesn't help either: on 12
knots it was the slowest option under sharing (68.6 ms shared, against 50.8 ms always writing and
35.4 ms never writing).
On jittered knots that look linear, idx_prev is never read, and removing the write changed
Linear by −8% to +5%. CubicSpline on those knots was 8–19% slower with random queries, which I
can't explain; it may be codegen. The full table is below.
Possible directions (your call)
- Skip the writes where nothing reads
idx_prev(linear_lookupistrue, and the closed-form
Linear path). - Let users opt out of the stored hint for interpolants shared across threads, for example with a
constructor keyword or a stateless guesser, and keep today's behavior as the default. - At minimum, note in the thread-safety test and the docs that evaluation updates a search hint.
Then a copy per thread is the documented choice for heavy multithreaded use.
Workaround for now: give each thread its own deepcopy of the interpolant.
Full size table (master → no write)
| Knots | Spacing | Method | Hint reads idx_prev |
Sorted, 1 thread | Random, 1 thread | Random, 8 threads, shared | Random, 8 threads, copies |
|---|---|---|---|---|---|---|---|
| 12 | clustered | CubicSpline | yes | 21.4 → 21.1 (−1%) | 27.4 → 25.7 (−6%) | 46.4 → 25.9 (−44%) | 31.6 → 26.0 (−18%) |
| 12 | clustered | Linear | yes | 9.5 → 10.4 (+10%) | 19.0 → 17.7 (−7%) | 24.8 → 18.2 (−27%) | 19.8 → 18.1 (−9%) |
| 12 | jittered | CubicSpline | no | 21.5 → 23.0 (+7%) | 28.8 → 33.4 (+16%) | 28.4 → 32.8 (+15%) | 28.4 → 32.9 (+16%) |
| 12 | jittered | Linear | no | 11.6 → 10.8 (−7%) | 19.6 → 19.9 (+2%) | 20.1 → 21.2 (+5%) | 20.9 → 21.4 (+2%) |
| 100 | clustered | CubicSpline | yes | 23.4 → 36.0 (+54%) | 46.0 → 42.7 (−7%) | 59.4 → 47.8 (−20%) | 52.3 → 48.3 (−8%) |
| 100 | clustered | Linear | yes | 16.4 → 22.4 (+37%) | 35.5 → 30.3 (−15%) | 45.6 → 31.6 (−31%) | 37.8 → 31.6 (−16%) |
| 100 | jittered | CubicSpline | no | 28.8 → 28.2 (−2%) | 31.4 → 35.5 (+13%) | 34.6 → 36.1 (+4%) | 34.7 → 35.9 (+3%) |
| 100 | jittered | Linear | no | 14.6 → 13.4 (−8%) | 21.3 → 21.0 (−1%) | 21.4 → 21.7 (+1%) | 21.5 → 21.7 (+1%) |
| 1000 | clustered | CubicSpline | yes | 23.4 → 54.4 (+132%) | 60.5 → 55.9 (−8%) | 75.4 → 65.2 (−13%) | 69.2 → 65.0 (−6%) |
| 1000 | clustered | Linear | yes | 16.2 → 37.6 (+133%) | 49.6 → 39.9 (−19%) | 64.1 → 41.3 (−36%) | 53.2 → 41.4 (−22%) |
| 1000 | jittered | CubicSpline | no | 33.3 → 35.2 (+6%) | 33.0 → 39.1 (+19%) | 37.7 → 37.7 (−0%) | 37.8 → 37.8 (+0%) |
| 1000 | jittered | Linear | no | 15.5 → 15.1 (−3%) | 21.2 → 20.6 (−3%) | 21.8 → 21.3 (−2%) | 21.8 → 21.3 (−2%) |
| 100000 | clustered | CubicSpline | yes | 27.5 → 100.8 (+267%) | 147.4 → 133.6 (−9%) | 176.4 → 139.8 (−21%) | 170.7 → 142.8 (−16%) |
| 100000 | clustered | Linear | yes | 16.7 → 74.2 (+345%) | 126.6 → 91.9 (−27%) | 147.2 → 97.4 (−34%) | 140.4 → 97.6 (−30%) |
| 100000 | jittered | CubicSpline | no | 32.5 → 36.7 (+13%) | 44.1 → 47.7 (+8%) | 51.7 → 49.1 (−5%) | 58.9 → 57.1 (−3%) |
| 100000 | jittered | Linear | no | 18.5 → 17.1 (−7%) | 24.1 → 23.8 (−1%) | 25.0 → 25.1 (+0%) | 25.2 → 25.3 (+0%) |
Times in ms. The no-write branch removes the two lines quoted at the top and nothing else.
bench_sizes.jl (the table above; run once per checkout)
# Evaluation cost with and without the `idx_prev` write, by grid size, knot spacing and query order.
# Run once per DataInterpolations checkout (master, then the no-write branch), e.g.
# julia --project=<env with that checkout dev'd> -t 8 bench_sizes.jl
# Clustered knots (cubed) don't look linear, so the hint is the cached `idx_prev`; jittered knots look
# linear, so the hint is computed from `t` and `idx_prev` is never read.
using DataInterpolations, Random, Printf
const NQ = 1_000_000
work(A, x) = (s = 0.0; for xi in x; s += A(xi); end; s)
best(f) = minimum(@elapsed(f()) for _ in 1:5)
function threaded(interps, xs)
out = zeros(length(xs))
Threads.@threads :static for k in eachindex(xs)
out[k] = work(interps[k], xs[k])
end
return out
end
println("DataInterpolations ", pkgversion(DataInterpolations), " at ", pkgdir(DataInterpolations), ", ", Threads.nthreads(), " threads")
for n in (12, 100, 1_000, 100_000), spacing in (:clustered, :jittered), method in (:Linear, :CubicSpline)
t = spacing === :clustered ? 100 .* range(0.0, 1.0; length = n) .^ 3 :
sort!(100 .* (range(0.0, 1.0; length = n) .+ (0.2 / n) .* rand(Xoshiro(1), n)))
u = sin.(t ./ 7)
A = method === :Linear ? LinearInterpolation(u, t) : CubicSpline(u, t)
lo, hi = first(t), last(t)
random = lo .+ (hi - lo) .* rand(Xoshiro(2), NQ)
sorted = sort(random)
xs = [lo .+ (hi - lo) .* rand(Xoshiro(10 + k), NQ) for k in 1:Threads.nthreads()]
shared, copies = fill(A, length(xs)), [deepcopy(A) for _ in xs]
work(A, sorted); work(A, random); threaded(shared, xs); threaded(copies, xs)
@printf("%-7d %-9s %-11s %-18s lookup=%-5s | 1 thread: sorted %7.2f ms, random %7.2f ms | %d threads random: shared %7.2f ms, copies %7.2f ms\n",
n, spacing, method, A.kind, A.iguesser.linear_lookup,
1e3 * best(() -> work(A, sorted)), 1e3 * best(() -> work(A, random)),
length(xs), 1e3 * best(() -> threaded(shared, xs)), 1e3 * best(() -> threaded(copies, xs)))
endexperiment.jl (the 12-knot "write only if changed" comparison)
# What the idx_prev write buys and costs. VARIANT=nowrite replaces get_idx with the same lookup minus the
# write (the hint is still read); VARIANT=changed writes only when the index differs from the cached one.
# Run: julia -t 8 experiment.jl, VARIANT=nowrite julia -t 8 experiment.jl, VARIANT=changed julia -t 8 experiment.jl
using DataInterpolations, Random
const Guesser = DataInterpolations.Guesser # FindFirstFunctions.Guesser, as DataInterpolations imports it
const VARIANT = get(ENV, "VARIANT", "current")
if VARIANT == "nowrite"
@eval DataInterpolations function get_idx(A::AbstractInterpolation, t, iguess::Guesser; lb = 1, ub_shift = -1, idx_shift = 0, side = :last)
raw = _dispatch_search(A, A.t, t, iguess(t), side)
return clamp(raw + idx_shift, lb, length(A.t) + ub_shift)
end
elseif VARIANT == "changed"
@eval DataInterpolations function get_idx(A::AbstractInterpolation, t, iguess::Guesser; lb = 1, ub_shift = -1, idx_shift = 0, side = :last)
raw = _dispatch_search(A, A.t, t, iguess(t), side)
idx = clamp(raw + idx_shift, lb, length(A.t) + ub_shift)
iguess.idx_prev[] == idx || (iguess.idx_prev[] = idx)
return idx
end
end
t = [0.5, 1.0, 2.0, 3.0, 5.0, 7.0, 10.0, 12.0, 15.0, 20.0, 25.0, 30.0]
u = 0.03 .+ 0.01 .* (1 .- exp.(-t ./ 5))
A = LinearInterpolation(u, t; cache_parameters = true)
const N = 2_000_000
xs = [0.5 .+ 29.5 .* rand(Xoshiro(k), N) for k in 1:Threads.nthreads()]
sorted = sort(xs[1])
work(A, x) = (s = 0.0; for xi in x; s += A(xi); end; s)
function run(interps)
out = zeros(Threads.nthreads())
Threads.@threads :static for k in 1:Threads.nthreads()
out[k] = work(interps[k], xs[k])
end
return out
end
shared = fill(A, Threads.nthreads()); copies = [deepcopy(A) for _ in 1:Threads.nthreads()]
run(shared); run(copies); work(A, sorted); work(A, xs[1])
ms(f) = round(1e3 * minimum(@elapsed(f()) for _ in 1:5); digits = 1)
println(rpad(VARIANT, 8),
" | threaded random: shared ", ms(() -> run(shared)), " ms, copies ", ms(() -> run(copies)),
" ms | one thread: sorted ", ms(() -> work(A, sorted)), " ms, random ", ms(() -> work(A, xs[1])), " ms")Output on the MWE's 12 non-uniform knots, 8 threads:
current | threaded random: shared 50.8 ms, copies 46.5 ms | one thread: sorted 18.3 ms, random 38.2 ms
nowrite | threaded random: shared 35.4 ms, copies 34.9 ms | one thread: sorted 19.9 ms, random 33.9 ms
changed | threaded random: shared 68.6 ms, copies 45.0 ms | one thread: sorted 17.4 ms, random 41.3 ms
Activity
alecloudenback commentedon Oct 3, 2026
Disclosure - issue identified and field by Opus 5.5 as it was working through some JuliaActuary enhancements.
ChrisRackauckas-Claude commentedon Oct 9, 2026
Thanks for the report and the disclosure. A draft fix is open in #619. When the lookup does not use
idx_prev, the fix skips the write toiguesser.idx_prev, so threads that share an interpolant no longer contend on it. It is still in automated review; once that is done it goes to a maintainer.🤖 Status update by an AI agent (Claude Code, model claude-opus-5-5[1m]) on behalf of Chris Rackauckas — https://claude.ai/code/session_01AXwyJDRaMKgnYMG9M1Ut49