Skip to content

Evaluation writes iguesser.idx_prev, so threads sharing an interpolant contend on it #618

Open

Description

@alecloudenback

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 + 1

This 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:

  1. 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 in test/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
    sc but not iguesser.
  2. 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_idx writes even when iguesser.linear_lookup is true, and the hint is then computed from t.
  • LinearInterpolation on uniform Vector knots 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)
end

Output (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_lookup is true, 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)))
end
experiment.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

  1. alecloudenback commented on Oct 3, 2026

    @alecloudenback
    ContributorAuthor

    Disclosure - issue identified and field by Opus 5.5 as it was working through some JuliaActuary enhancements.

  2. ChrisRackauckas-Claude commented on Oct 9, 2026

    @ChrisRackauckas-Claude
    Member

    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 to iguesser.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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions