Skip to content

ENH: spatial.KDTree: make thread-safe via immutability#24799

Merged
rgommers merged 10 commits intoscipy:mainfrom
ngoldbaum:thread-safe-kdtree
Mar 25, 2026
Merged

ENH: spatial.KDTree: make thread-safe via immutability#24799
rgommers merged 10 commits intoscipy:mainfrom
ngoldbaum:thread-safe-kdtree

Conversation

@ngoldbaum
Copy link
Copy Markdown
Contributor

@ngoldbaum ngoldbaum commented Mar 12, 2026

Towards fixing #20669. Supersedes #20655.

Currently, scipy.spatial.KDTree is mostly thread-safe. There are two major issues right now that this PR helps to fix:

  • KDTree.tree is lazily-initialized, which can lead to data races.
  • Some attributes of the KDTree are exposed as mutable ndarrays, which can cause corruption if they are modified, with multithreading or no.

I've converted KDTree.tree to use a py_safe single-initialization API, as described here in the Cython docs. These are new in Cython 3.2, so I bumped the minimum Cython version. There's also a compiler warning I can't fix that I reported to Cython.

I also made the ndarray attributes of the KDTree class be read-only. While there are still ways to modify a read-only NumPy array, this at least makes it harder for users to shoot themselves in the foot. I clarified the docstring for the data argument to expand the warnings about modifying the data if no copy happens. I noticed that the boxsize attribute is undocumented, so I added entries. I also updated the thread safety docs.

Finally, I added a multithreaded query_ball_point test and a multithreaded KDTree.tree generation test. I can add multithreaded tests for more of the query functions if you'd like me to do that. I also added a test to validate that KDTrees cannot be modified from Python.

I also updated the multithreaded test helper to fix a possible deadlock and allow tests to get return values from worker threads without relying on global state.

#20655 has a lot of discussion about modifying the KDTree before __init__ or __cinit__ finishes. I'm pretty sure that's all a red herring. It's not possible to modify an object before the initializer finishes, and initializers run in an effectively single-threaded context. I think confusion around that point led to the choice of adding a lock. I don't think that's actually needed.

I ran the KDTree tests, including the new multithreaded tests, under thread sanitizer on my Mac and see no data races.

@github-actions github-actions bot added scipy._lib scipy.spatial Documentation Issues related to the SciPy documentation. Also check https://github.com/scipy/scipy.org Cython Issues with the internal Cython code base labels Mar 12, 2026
assert_array_equal(t.maxes, np.amax(points, axis=0))
assert_array_equal(t.mins, np.amin(points, axis=0))
assert t.data is points
assert t.data.base is points
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is due to chaging data to be a read-only view. I'm not sure if it counts as a breaking change.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I played with this feature branch a bit locally, and T.data does still provide access to the points of course, so seems like it would be a mild break at worst.

There might even be a bug fix argument I suppose since it prevents a footgun. Maybe we could ping Sturla before merging if we're worried about it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That shouldn't count as a breaking change, more like a robustness improvement. The current docstring of data input argument already says: "This array is not copied unless this is necessary to produce a contiguous array of doubles, and so modifying this data will result in bogus results."

# to avoid deadlocks if spawning failed for some reason
barrier.abort()

return [f.result() for f in futures]
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The original implementation can deadlock if spawning one of the threads fails. I fixed that and also used a ThreadPoolExecutor, which allows me to return results from the worker functions.

@ngoldbaum
Copy link
Copy Markdown
Contributor Author

None of the CI failed due to using a too-new Cython feature. Maybe it is OK to bump the minimum Cython version?

@rgommers
Copy link
Copy Markdown
Member

These are new in Cython 3.2. I don't know whether it's OK to bump the minimum Cython version. It is currently set to Cython 3.0.8. If bumping the minimum Cython version is not OK, then I think I can vendor a version of py_safe_call_object_once inside of SciPy. There's also a compiler warning I can't fix that I reported to Cython.

It's okay to bump Cython to >=3.2. Ideally a Cython bug fix release with the compiler warning fixed would show up, but we can suppress the -Wunneeded-internal-declaration in this PR - can you add it to the checks here:

# C++ warning flags

and then add that to the extension module's cpp_args in spatial/meson.build?

@rgommers
Copy link
Copy Markdown
Member

This looks pretty good, thanks for working on this @ngoldbaum! I'll have a closer look once it's out of draft.

@ngoldbaum ngoldbaum force-pushed the thread-safe-kdtree branch from 018a536 to e0ab9df Compare March 19, 2026 15:44
assert_array_equal(t.maxes, np.amax(points, axis=0))
assert_array_equal(t.mins, np.amin(points, axis=0))
assert t.data is points
assert t.data.base is points
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I played with this feature branch a bit locally, and T.data does still provide access to the points of course, so seems like it would be a mild break at worst.

There might even be a bug fix argument I suppose since it prevents a footgun. Maybe we could ping Sturla before merging if we're worried about it.

# read-only view so ban people modifying tree.data after the tree is
# constructed
data = data.view(np.ndarray)
data.flags.writeable = False
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did wonder when I was reading this if the need for this is milder with copy_data=True? I guess you still have problems, even if a little less serious.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's still useful to set this read-only flag, as it insulates against mutating this .data attribute. It's milder indeed in that copy_data already insulates against the base array for the input data being mutated. But this seems like the right thing to do either way.

@ngoldbaum ngoldbaum marked this pull request as ready for review March 19, 2026 18:37
@ngoldbaum
Copy link
Copy Markdown
Contributor Author

I think this is ready now. I bumped the minimum version to 3.2.0 because I wasn't sure downstream builders would necessarily have 3.2.4. Let me know if you'd prefer it to be 3.2.4.

@ngoldbaum ngoldbaum changed the title Make KDTree thread-safe via immutability ENH: Make KDTree thread-safe via immutability Mar 19, 2026
@ngoldbaum
Copy link
Copy Markdown
Contributor Author

The last commit fixes a possible race that David Woods pointed out to me: cython/cython#7581 (comment).

We could go back to having a boolean flag but I'd need to use c++ standard library atomics to do that. I don't think the overhead of creating the wrapper object on every call matters much.

@lucascolley lucascolley added enhancement A new feature or improvement free-threading Items related to supporting free-threaded (a.k.a. "no-GIL") builds of CPython labels Mar 19, 2026
@ngoldbaum
Copy link
Copy Markdown
Contributor Author

but I'd need to use c++ standard library atomics to do that

Eh, the atomics aren't so bad, see the last example in cython/cython#7582. Should I just go ahead and do that, along with switching to a closure instead of a wrapper object?

@da-woods
Copy link
Copy Markdown
Contributor

I think it's possible to avoid the closure and the atomics by using a global cdef function and casting the captured self to/from void. See cython/cython#7582 (comment) where it's written out slightly more fully.

@lucascolley lucascolley changed the title ENH: Make KDTree thread-safe via immutability ENH: spatial. KDTree thread-safe via immutability Mar 20, 2026
@lucascolley lucascolley changed the title ENH: spatial. KDTree thread-safe via immutability ENH: spatial.KDTree: make thread-safe via immutability Mar 20, 2026
@rgommers
Copy link
Copy Markdown
Member

@ngoldbaum given the Cython bug fix and discussion around avoiding wrapper, can you confirm whether the minimum Cython version this PR needs in its current form is 3.2.0 or 3.2.5?

@da-woods
Copy link
Copy Markdown
Contributor

can you confirm whether the minimum Cython version this PR needs in its current form is 3.2.0 or 3.2.5?

The version here should work with 3.2.0 I believe

@ngoldbaum
Copy link
Copy Markdown
Contributor Author

can you confirm whether the minimum Cython version this PR needs in its current form is 3.2.0 or 3.2.5?

I just double-checked: this branch builds in an environment with Cython 3.2.0 installed.

da-woods pushed a commit to cython/cython that referenced this pull request Mar 24, 2026
Copy link
Copy Markdown
Member

@rgommers rgommers left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I went over this again, and all looks good - time to get it in. Thanks @ngoldbaum & reviewers!

# read-only view so ban people modifying tree.data after the tree is
# constructed
data = data.view(np.ndarray)
data.flags.writeable = False
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's still useful to set this read-only flag, as it insulates against mutating this .data attribute. It's milder indeed in that copy_data already insulates against the base array for the input data being mutated. But this seems like the right thing to do either way.

@rgommers rgommers merged commit 5ca1cdc into scipy:main Mar 25, 2026
57 of 58 checks passed
@github-actions github-actions bot added the needs-release-note-decision A maintainer should decide if this PR requires a release note. label Mar 25, 2026
@lucascolley lucascolley added needs-release-note A maintainer should add a release note written by a reviewer/author to the wiki. and removed needs-release-note-decision A maintainer should decide if this PR requires a release note. labels Mar 27, 2026
@lucascolley
Copy link
Copy Markdown
Member

ASan CI has been a bit shaky since this PR was merged, not sure if it's related at all but either way maybe somebody involved here would be well-placed to investigate #24980

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

Labels

Cython Issues with the internal Cython code base Documentation Issues related to the SciPy documentation. Also check https://github.com/scipy/scipy.org enhancement A new feature or improvement free-threading Items related to supporting free-threaded (a.k.a. "no-GIL") builds of CPython needs-release-note A maintainer should add a release note written by a reviewer/author to the wiki. scipy._lib scipy.spatial

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants