Skip to content

Reimplement Rtree using pybind11 - #425

Closed
hobu wants to merge 5 commits into
mainfrom
pybind11
Closed

hobu wants to merge 5 commits into
mainfrom
pybind11

Conversation

@hobu

@hobu hobu commented Sep 25, 2026

Copy link
Copy Markdown
Member

Below is LLM output from Claude. I had it work through implementing the bindings in pybind11. I wanted to see what the performance improvement and readability benefits would be in addition to getting type annotations at the binding layer. It looks like a significant win if we are willing to live with the dependency changes.

Replace the ctypes bindings with a typed, compiled extension (nanobind, abi3)

Closes #223.

Summary

rtree has always talked to libspatialindex through ~700 lines of hand-written ctypes prototypes (rtree/core.py) against the libspatialindex C API.

This PR replaces them with a compiled extension, rtree._core, that binds the libspatialindex C++ API directly. libspatialindex_c is no longer used.

The main goal was proper type hints for the binding layer and the main library. Along the way the port made rtree faster, fixed two long-standing bugs, and simplified packaging.

  • Typed end to end. The binding layer is checked by the compiler against the real headers and ships a generated rtree/_core.pyi. rtree/index.py is fully annotated and clean under mypy --strict; on main, rtree/ has
    270 strict errors.
  • Faster. Object queries are 5–7.6× faster, stream bulk loading 3.3×, and id queries 1.4–1.8×. The fixed binding cost per call is ~4× lower (details below).
  • NumPy-native. Id queries return int64 arrays, and coordinates are parsed in C++, including straight from NumPy arrays.
  • One wheel per platform. The extension targets the CPython 3.12 stable ABI via nanobind, so a single cp312-abi3 wheel serves Python 3.12, 3.13, 3.14 and later. libspatialindex is linked in statically, so the shared-library bundling, finder-based loading and repair_wheel.py retagging are gone.
  • Requires Python ≥ 3.12 and NumPy.

The branch got there in steps, each benchmarked:

  1. ctypes → pybind11 against the C API.
  2. pybind11 against the C++ API.
  3. Coordinates parsed in C++, ndarray results.
  4. pybind11 → nanobind with abi3.

docs/pybind11-port.md has the full notes and the numbers for each step.

What changed

Bindings (src/_core.cpp, new)

  • The extension drives SpatialIndex::ISpatialIndex, IVisitor, IQueryStrategy and IStorageManager itself. What libspatialindex_c used to provide now lives in rtree's own ~200 lines of C++: index setup (storage → buffer → tree from a PropertySet with the same defaults), id, object and count visitors, bounds and leaf queries, point-vs-region insert detection, result paging, and stream and array bulk loaders.
  • IndexHandle, PropertyHandle and IndexItem are owned C++ objects with deterministic cleanup. The index keeps its properties alive, and teardown runs tree → buffer → storage.
  • Errors are C++ exceptions translated to RTreeError once, carrying libspatialindex's own message. There's no global error stack to poll, and no errors swallowed by the C shim.
  • The GIL is released around index calls and re-acquired in callbacks.
  • Custom storage:
    • CustomStorage is a C++ IStorageManager that calls the Python methods with int/bytes. Callbacks get an ErrorRef that supports both err.value = X and the old err.contents.value = X.
    • CustomStorageBase, whose callbacks work on raw ctypes buffers, still works through its ctypes callback table.
  • Coordinates are parsed in C++ from any sequence of numbers. There's a fast path for 1-D float64 buffers (NumPy arrays, array.array('d'), strided views), and stream bulk loading consumes the iterator in C++.
  • Only limited-API C calls are used directly; abi3audit --strict is clean. Id results are built with numpy.frombuffer. That's about 1 µs per call cheaper than nanobind's generic ndarray export, and the arrays stay writable.
  • Feature detection happens at build time (CMake probes against the headers) instead of try: rt.Foo except AttributeError at import time.
  • Supported libspatialindex versions: 1.8.5 and newer, tested with 1.8.5,
    1.9.3 and 2.1.0. contains() now also works on 1.8.5 because
    containsWhatQuery was always in the C++ API.

Python layer

  • rtree/index.py calls the typed handles and is fully annotated. pyproject.toml enables disallow_untyped_defs for rtree.index and rtree._core.
  • rtree/_core.pyi is generated by nanobind.stubgen (scripts/generate_stubs.sh). NumPy parameters carry dtype, shape and writability.
  • rtree/core.py re-exports the _core objects. core.rt (the raw CDLL) is removed.
  • InvalidHandleException moved to rtree.exceptions and is re-exported from rtree.index.
  • Typing the binding layer exposed several annotations that were wrong and had been hidden behind Any, now fixed:
    • fill_factor and split_distribution_factor were annotated int; they are C doubles.
    • _countTP(times: float) actually takes a (start, end) pair.
    • get_coordinate_pointers() claimed to return tuple[float, float].
  • interleave/deinterleave are generic. intersection_v, nearest_v and leaves() have real return types. intersection/nearest/contains overloads return NDArray[np.int64] for objects=False.

Build, packaging and repository

  • Build backend: setuptools → scikit-build-core + CMake + nanobind (nanobind_add_module(_core STABLE_ABI NOMINSIZE ...), wheel.py-api = "cp312"). setup.py is removed.
  • scripts/install_libspatialindex.{sh,bat} build a static, position-independent libspatialindex (a universal build on macOS) into sidx-static/, which CMake picks up.
  • cibuildwheel builds each platform's abi3 wheel once and tests it on 3.12–3.14 with standard auditwheel/delocate/delvewheel repair. CI runs abi3audit --strict on the wheels. scripts/repair_wheel.py is removed.
  • CI:
    • The conda and Ubuntu jobs compile the extension and cover 3.12–3.14. They use editable installs so the in-tree rtree/ picks up the built module.
    • The Ubuntu job installs libspatialindex-dev.
    • Read the Docs installs the package, because the docs import the extension.
  • DEPENDENCIES.txt lists the runtime requirements (Python 3.12+, NumPy) and the build requirements (libspatialindex C++ headers, C++17, CMake, nanobind).
  • MANIFEST.in mirrors the sdist. scikit-build-core doesn't read it: dev-only files are excluded via sdist.exclude, and check-manifest passes.
  • FAQ.txt (empty) is removed.
  • README and docs no longer describe rtree as a ctypes wrapper. The install docs cover source builds. environment.yml targets 3.12 and adds the build tools.
  • benchmarks/bindings.py is a binding-layer benchmark, used for the numbers
    below.

Bug fixes (both present on main)

  • result_limit and result_offset were cross-wired. Each setter and getter called the other C function, so the round-trip tests passed. On main, idx.result_offset = 3 limits results to 3 and idx.result_limit = 3 skips the first 3. Covered by test_result_limit_and_offset_semantics.
  • CustomStorageBase could not be used. registerCallbacks passed a ctypes.c_void_p() that got wrapped in a second c_void_p, raising TypeError: cannot be converted to pointer. There was no test; covered by
    IndexCustomStorageBase.

Performance

benchmarks/bindings.py, same machine, CPython 3.12, libspatialindex 2.1.0:
100k random boxes, 20k 10×10 query windows, best of 2 repetitions.

operation main (ctypes) this PR speed-up
intersection, objects=True, 20k queries 3183 ms 419 ms 7.6×
nearest k=5, objects=True, 20k queries 3625 ms 692 ms 5.2×
objects=True, 100k hits ×5 9346 ms 1574 ms 5.9×
objects="raw", 100k hits ×5 2502 ms 605 ms 4.1×
intersection, objects="raw", 20k queries 604 ms 277 ms 2.2×
stream bulk load 100k 218 ms 67 ms 3.3×
intersection → ids as ndarray, 20k queries 370 ms 207 ms 1.8×
intersection → ids, NumPy query coordinates, 20k queries 349 ms 196 ms 1.8×
intersection → ids, 20k queries 343 ms 194 ms 1.8×
intersection → list(ids), 20k queries 332 ms 214 ms 1.6×
ids, 100k hits ×5 100 ms 54 ms 1.9×
nearest k=5 → ids, 20k queries 657 ms 484 ms 1.4×
count, 20k queries 231 ms 176 ms 1.3×
bounds ×20k 198 ms 16 ms 12×
leaves() 26 ms 6 ms 4.5×
insert 100k / with obj 20k 3320 / 842 ms 3120 / 738 ms 1.06× / 1.14×
contains → ids, 20k queries 5012 ms 4826 ms 1.04×
intersection_v / nearest_v / array bulk load 176 / 464 / 57 ms 179 / 462 / 61 ms ~1.0×
delete 20k 9915 ms 10146 ms ~1.0×

Fixed cost per call, measured with a query that matches nothing:

call main (ctypes) this PR
Index.count 2.20 µs 0.51 µs
Index.intersection 3.17 µs 0.78 µs

Where the time goes:

  • Binding overhead is ~4× lower per call. What's left in a typical query is libspatialindex's own tree search, about 8.5 µs for these windows, which no binding can remove. The same applies to intersection_v/nearest_v (already one C call for all queries), insert, delete and contains: they're bound by libspatialindex itself.
  • Object queries gain the most. The C API cloned every hit twice and copied its payload several times. The C++ visitor now extracts id, bounds and data once, and building the result objects is cheap in nanobind.
  • Paged queries apply the limit and offset while visiting, so skipped hits are never built. A "paged objects" run shows ~180×, but that is mostly the result_limit bug on main returning every hit instead of 10. Paged id queries are 1.9× faster.
  • The ndarray helps when used as an array. list(idx.intersection(...)) pays to box each np.int64; use .tolist() to get Python ints.
  • Stable ABI vs pybind11. On the same 3.12 build, nanobind with abi3 is on par with the pybind11 version overall and 8–20% faster on object-heavy queries. Stream bulk loading is ~6–8% slower, because limited-API tuple access can't be inlined.

Behaviour changes

  • Python ≥ 3.12 is required, and NumPy is a runtime dependency.
  • intersection(), nearest() and contains() with objects=False return numpy.ndarray[int64] instead of an iterator of int. Elements are np.int64: they compare and hash like int, but json.dumps rejects them and NumPy 2 prints them as np.int64(3). The tutorial doctests now use .tolist(). RtreeContainer is unchanged for users.
  • min <= max is validated per dimension. The old check compared lists lexicographically, so a box like (xmin=0, ymin=5, xmax=1, ymax=2) was accepted as an inverted box; it now raises RTreeError. Stream bulk loading still doesn't validate, as before.
  • result_limit/result_offset now do what their names say.
  • A non-integer id raises TypeError instead of ctypes.ArgumentError.
  • rtree.core.rt is removed.
  • Property.filename and the extension properties accept str or bytes and return str. Property.custom_storage_callbacks returns an int address or None.
  • Stream items may be points, and coordinates may be any sequence or 1-D array.
  • Building from source needs a C++17 compiler and libspatialindex headers.

Notes for reviewers

  • macOS/libc++ exceptions. libc++ matches exception types by type_info address, and extensions are built with hidden visibility, so exceptions thrown inside a shared libspatialindex were not caught. The libspatialindex
    include is wrapped in #pragma GCC visibility push(default) to fix this, and test_empty_stream asserts libspatialindex's real message so it can't regress silently.
  • Old libspatialindex versions.
    • Before 2.0, the PropertySet bulk-load overload has a 0-byte external sort buffer, so the explicit overload is used there instead.
    • The 1.8.5 headers use std::binary_function, so _HAS_AUTO_PTR_ETC and _LIBCPP_ENABLE_CXX17_REMOVED_UNARY_BINARY_FUNCTION are set for MSVC/libc++ builds.
  • C++ ABI coupling. Shared-library builds (conda, distros) must use a C++ standard library ABI-compatible with the one that built libspatialindex. This doesn't affect the static wheels.
  • Free-threaded CPython (3.13t/3.14t) can't load abi3 wheels, and the binding isn't declared thread-safe yet: libspatialindex keeps per-index state without locking.
  • nanobind builtin exceptions (nb::value_error, ...) derive from std::runtime_error. run() only wraps libspatialindex calls, so they aren't re-wrapped as RTreeError. Keep that in mind when adding code inside run().
  • Upstream C API issues found while binding it, now moot for rtree:
    • Error_Reset/Error_GetErrorCount are exported but not declared in
      sidx_api.h.
    • Index_Free(NULL) pushes an error.
    • Index_Intersects_obj returns success after an error.
    • IndexProperty_Set* string setters leak.

Testing

  • The same abi3 wheel passes the test suite on CPython 3.12, 3.13 and 3.14, and abi3audit --strict reports only 3.12 stable-ABI symbols.
  • The tests and doctests pass against libspatialindex 1.8.5, 1.9.3 (Ubuntu libspatialindex-dev) and 2.1.0, with static and shared builds and with GCC/libstdc++ and clang/libc++.
  • mypy --strict, ruff, check-manifest and the Sphinx build with -W are all clean. nanobind reports no leaked instances, and memory stays flat over repeated queries.
  • GitHub Actions was green on the pybind11 revision of this branch. The nanobind/abi3 commit still needs its first CI run, including the first run of the cibuildwheel/abi3 wheel jobs.
  • New tests:
    • test_result_limit_and_offset_semantics
    • IndexCustomStorageBase
    • CoordinateParsing: array returns, input types, points, non-interleaved
      input, per-dimension validation, streams of arrays
    • test_empty_stream now asserts the error message

hobu and others added 5 commits September 24, 2026 15:05
Replace the hand-maintained ctypes prototypes in rtree/core.py with a
pybind11 extension (src/_core.cpp) that binds the libspatialindex C API
one-to-one using Python types, plus a generated rtree/_core.pyi stub.

- index.py uses the typed handles; fully annotated, mypy --strict clean
- build: setuptools -> scikit-build-core + CMake; feature detection at
  build time (Contains, array API); supports libspatialindex 1.8.5+
- wheels: static libspatialindex linked into the extension, one wheel
  per CPython version; drop repair_wheel.py and lib bundling
- fix result_limit/result_offset being cross-wired to the C API
- fix CustomStorageBase raising TypeError on registration
- CustomStorage callbacks get an ErrorRef compatible with the old
  returnError.contents.value idiom

See docs/pybind11-port.md for findings, packaging impact, and benchmarks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ATspgBTE6vTSYGpTJUZVCE
Rewrite src/_core.cpp against SpatialIndex::ISpatialIndex, IVisitor,
IQueryStrategy and IStorageManager, dropping libspatialindex_c. The
Python-facing rtree._core API (and _core.pyi signatures) is unchanged.

- own visitors/strategies for ids, objects, counts, bounds and leaves;
  object results are extracted once instead of cloned twice
- result_limit/result_offset applied while visiting
- C++ exceptions translated to RTreeError; no C error stack
- Python and ctypes custom storage implemented as IStorageManagers
- contains() now also works with libspatialindex 1.8.5
- CMake links only libspatialindex; probes nearestNeighborQuery max_dist
  and ISpatialIndex::flush (both >= 1.9)
- add benchmarks/bindings.py; document C API vs C++ API results

Speed is unchanged for typical queries; object queries with many hits are
~1.1-1.2x and paged object queries ~2x faster. See docs/pybind11-port.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ATspgBTE6vTSYGpTJUZVCE
- IndexHandle.insert/delete/count/intersection/contains/nearest (and the
  *_obj variants) take the caller's coordinates plus the interleaved flag;
  point/box splitting, deinterleaving and validation happen in C++, with
  a fast path for 1-D float64 buffers
- id queries return int64 NumPy arrays that own the C++ result vector
  (no copy); TPR id queries too
- stream bulk loading consumes the Python iterator in C++
- min <= max is validated per dimension (was a lexicographic list compare)
- numpy becomes a required dependency
- tutorial doctests use .tolist(); new CoordinateParsing tests

Per-call binding overhead drops from ~1.9 to ~1.05 us; small queries are
1.1-1.25x faster, stream bulk load 1.75x. See docs/pybind11-port.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ATspgBTE6vTSYGpTJUZVCE
With a shared libspatialindex and libc++ (macOS, or clang on Linux),
exception types are matched by type_info address. pybind11 compiles the
module with -fvisibility=hidden, so catch (Tools::Exception&) never
matched exceptions thrown inside libspatialindex: from_stream let them
escape as "RuntimeError: Caught an unknown exception!" (test_empty_stream)
and every other call reported a generic "Unknown Error".

Include the libspatialindex headers with default visibility, add a
catch-all to from_stream, and make test_empty_stream check the library's
own error message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ATspgBTE6vTSYGpTJUZVCE
Ubuntu (libspatialindex 1.9.3): the PropertySet overload of
createAndBulkLoadNewRTree defaults the external sorter to 0-byte pages
before 2.0 and fails with "BufferedFileWriter::open: Cannot open file"
(IndexSerialization::test_interleaving). Use the explicit overload for
< 2.0, as libspatialindex_c did.

Windows conda (libspatialindex 1.8.5): the 1.8.5 headers derive from
std::binary_function, removed in C++17 by MSVC's STL and libc++. Define
_HAS_AUTO_PTR_ETC / _LIBCPP_ENABLE_CXX17_REMOVED_UNARY_BINARY_FUNCTION
before any standard header, and for the CMake feature probes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ATspgBTE6vTSYGpTJUZVCE
@sgillies

Copy link
Copy Markdown
Member

👎 You should start a new project for this kind of thing.

@adamjstewart

Copy link
Copy Markdown
Collaborator

To clarify, I'm all for switching to pybind11, just not like this. This is essentially a complete rewrite, is arguably not copyrightable, and decreases readability by nearly doubling the size of the library. I for one do not want to live in a world where the only maintainer who understands the current implementation is Claude. But Sean and I have been pretty vocal about our dislike of LLMs, so maybe we're just old-fashioned.

@hobu

hobu commented Sep 25, 2026

Copy link
Copy Markdown
Member Author

You should start a new project for this kind of thing.

Sure. It was just an investigation to see if it would be worth the bother to switch to pybind11.

I for one do not want to live in a world where the only maintainer who understands the current implementation is Claude.

A pybind11 implementation is harder to maintain regardless of whether an LLM or a doughy software developer wrote it. Now that maintainer must know C++, Python -> C+ boundary stuff, and CMake / scikit-build-core for building things. I would argue our ctypes implementation hasn't been particularly maintainable over the past twenty years either – Claude found some basic errors that still exist in the codebase where we're swapping parameters and mixing types because there's no way to test that stuff.

I think the practical answer is no one is going to invest the time to do this port manually. There are performance wins to get, but they yet haven't mattered enough to anyone.

@hobu hobu closed this Sep 25, 2026
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.

Port bindings to pybind11?

3 participants