packed_spatial_index
packed_spatial_index is a packed static spatial index for 2D and 3D
axis-aligned bounding boxes.
It is built for read-heavy workloads where the full set of boxes is known up
front: build once, then run many window/intersection searches. The default
Index2D and Index3D use packed Hilbert R-tree layouts. With the simd
feature, SimdIndex2D and SimdIndex3D store boxes in structure-of-arrays form
and use SIMD intersection checks. Scalar and SIMD indexes share the same
canonical byte format for owned persistence.
use ;
let mut builder = new;
builder.add;
builder.add;
let index = builder.finish?;
let hits = index.search;
assert_eq!;
# Ok::
Installation
Requires Rust 1.89 or newer.
[]
= "0.3"
When To Use It
Use this crate when:
- your geometry is static or rebuilt in batches;
- search results can be returned as insertion-order indices into your own payload array;
- you want a compact in-memory index with reusable buffers for repeated searches;
- batch search throughput matters.
It is not a dynamic R-tree: there are no insert/delete operations after build.
Limitations
- The index is static: rebuild it when the dataset changes.
- 2D and 3D axis-aligned bounding boxes are supported.
- Search results are item indices, not stored payloads or geometries.
- Result ordering is not a stable API guarantee.
- Persistence uses canonical
Index2DandIndex3Dbyte layouts.SimdIndex2DandSimdIndex3Dcan save and load those same bytes, but there is no separate zero-copy SoA view format yet. - Nearest-neighbor search is exact over indexed boxes; approximate KNN and dynamic spatial joins are out of scope for now.
Main Types
Box2Dis the public AABB type, with inclusiveoverlaps,contains, andcontains_pointhelpers.Box2D::newis unchecked; useBox2D::try_newfor untrusted coordinate bounds.Box3DandPoint3Dare the equivalent scalar 3D geometry types.Index2DBuilderbuilds eitherIndex2Dor, withsimd,SimdIndex2D.Index3DBuilderbuilds eitherIndex3Dor, withsimd,SimdIndex3D.Index2Dis the default read-only index.Index3Dis the scalar read-only 3D index.Index2DViewandIndex3DVieware zero-copy read-only views over bytes produced by scalar or SIMDto_bytesmethods.SimdIndex2DandSimdIndex3Dare available with thesimdfeature and have the same search API and owned persistence API as their scalar counterparts.SearchWorkspacereuses result and traversal buffers.Point2D,Point3D, andNeighborWorkspacesupport nearest-neighbor searches.SortKey2Dselects the public build ordering curve.Hilbertis the stable default.SortKey3Ddoes the same for 3D.Hilbertis the stable default.
Search APIs:
extent()returns the total item box, orNonefor an empty index.search(query)allocates and returns aVec<usize>.search_into(query, &mut results)reuses a result buffer.search_with(query, &mut workspace)reuses result and traversal buffers.any(query),first(query), andvisit(query, visitor)support early exit.
Nearest-neighbor APIs:
neighbors(point, max_results)returns nearest item indices.neighbors_within(point, max_results, max_distance)caps the search radius.neighbors_into(...)andneighbors_with(...)reuse buffers.visit_neighbors(point, max_distance, visitor)visits(index, distance_squared)pairs.
Builder
use ;
let mut builder = new
.node_size
.sort_key;
builder.add;
builder.add;
With parallel enabled:
# use ;
let builder = new
.parallel
.parallel_min_items;
With simd enabled:
# use ;
let mut builder = new;
builder.add;
let simd_index = builder.finish_simd?;
# Ok::
The same finish_simd() method is available on Index3DBuilder and returns
SimdIndex3D.
3D uses the same builder/search shape:
use ;
let mut builder = new;
builder.add;
builder.add;
let index = builder.finish?;
assert_eq!;
assert_eq!;
# Ok::
Persistence
Index2D and Index3D can be serialized to stable little-endian byte formats
and loaded back either as owned indexes or as zero-copy views:
use ;
let mut builder = new;
builder.add;
let index = builder.finish?;
let bytes = index.to_bytes;
let mut reusable = Vecnew;
index.to_bytes_into;
assert_eq!;
let owned = from_bytes?;
let view = from_bytes?;
assert_eq!;
assert_eq!;
# Ok::
3D persistence uses the same header and sections, with a dimension flag and
six f64 coordinates per stored box. With the simd feature,
SimdIndex2D and SimdIndex3D read and write the same canonical bytes as the
scalar indexes. Loading a SIMD index is an owned load that scatters canonical
box records into SoA columns; zero-copy views remain scalar-only.
The binary layout is documented in FORMAT.md.
Examples
Runnable examples cover the public paths:
Feature-gated experiment examples include parallel_experiment_2d,
parallel_experiment_3d, soa_experiment_2d, soa_experiment_3d,
nodesize_experiment_2d, and nodesize_experiment_3d.
Features
Both features are enabled by default:
parallel: adaptive rayon-based index builds throughIndex2DBuilder::parallelandIndex3DBuilder::parallel.simd: SoA index and SIMD search paths throughSimdIndex2DandSimdIndex3D, plus owned persistence through the canonical byte format.
Minimal build:
SIMD-only or parallel-only builds:
Safety
The public API is safe Rust; users do not need unsafe to build, load, search,
or query neighbors.
Internally, the crate keeps unsafe limited to narrow performance-sensitive
paths:
- unaligned little-endian reads for validated
Index2DViewandIndex3DViewbyte buffers; - bulk byte copies for
repr(C)boxes and index sections when serializing on compatible little-endian targets; - x86/x86_64 prefetch intrinsics used only by hidden benchmark/experimental paths;
- AVX-512 loads in the
simdfeature, guarded by runtime CPU feature detection.
Loaded buffers are validated before they can be searched, so malformed input is
reported as LoadError instead of relying on caller-side invariants.
Performance Notes
Recent local Criterion runs, lower is better. The 2D competitor workload uses 100,000 random AABBs and 1,000 random search windows; build and search competitors are measured in the same benchmark suite on the same generated inputs. Persistence rows use the canonical byte format for 100,000 boxes.
| Benchmark | FlatGeobuf | static_aabb2d_index |
Index2D |
SimdIndex2D |
|---|---|---|---|---|
| Full build | 70.18 ms | 8.95 ms | 3.18 ms serial / 2.20 ms parallel | - |
| Search batch | 555.83 us | 341.56 us | 416.15 us | 128.64 us |
| Serialize built tree (fresh buffer) | - | - | 407.64 us | 689.42 us |
| Serialize built tree (reused buffer) | 131.93 us | - | 68.78 us | 140.21 us |
| Load owned tree | 740.23 us | - | 607.62 us | 935.51 us |
| Load zero-copy view | - | - | 37.59 us | n/a |
Scalar Index2D search versus static_aabb2d_index is dataset-sensitive.
Two local search runs with the same item/query counts but different generated
inputs showed opposite scalar ordering:
| Search batch | static_aabb2d_index |
Index2D |
SimdIndex2D |
|---|---|---|---|
flatgeobuf2d_bench, seed 0xF6B |
341.56 us | 416.15 us | 128.64 us |
index2d_bench, seed 0xB0B |
643.85 us | 311.72 us | 126.21 us |
Recent local 2D-vs-3D Criterion run. Lower latency is better. The 3D speed
column is 2D latency / 3D latency, so values above 1.00x mean 3D is faster.
The build workload uses 100,000 boxes with node_size = 16; search and KNN use
1,000 query windows or points.
| Stage | Dataset / mode | Index2D |
Index3D |
3D speed |
|---|---|---|---|---|
| Hilbert encode | production 2D LUT vs 3D nibble LUT | 739.90 us | 983.42 us | 0.75x |
| Build | planar XY | 2.7433 ms | 4.5660 ms | 0.60x |
| Build | uniform XYZ | 3.4270 ms | 5.1252 ms | 0.67x |
| Search batch | planar XY | 466.41 us | 636.37 us | 0.73x |
| Search batch | uniform XYZ | 495.82 us | 391.32 us | 1.27x |
| KNN batch | Dataset / mode | Index2D |
Index3D |
3D speed |
|---|---|---|---|---|
| Top-1 | planar XY | 973.85 us | 1.2445 ms | 0.78x |
| Top-10 | planar XY | 1.9378 ms | 2.5625 ms | 0.76x |
| Top-1 | uniform XYZ | 1.0028 ms | 1.8530 ms | 0.54x |
| Top-10 | uniform XYZ | 2.0221 ms | 4.1143 ms | 0.49x |
| Persistence | Index2D |
Index3D |
3D speed |
|---|---|---|---|
| Serialize built tree (fresh buffer) | 407.64 us | 562.55 us | 0.72x |
| Serialize built tree (reused buffer) | 68.78 us | 96.51 us | 0.71x |
| Load owned tree | 607.62 us | 819.04 us | 0.74x |
| Load zero-copy view | 37.59 us | 37.66 us | 1.00x |
| SIMD persistence | SimdIndex2D |
SimdIndex3D |
3D speed |
|---|---|---|---|
| Serialize built tree (fresh buffer) | 689.42 us | 973.17 us | 0.71x |
| Serialize built tree (reused buffer) | 140.21 us | 240.51 us | 0.58x |
| Load owned tree | 935.51 us | 1.3083 ms | 0.72x |
Recent local 3D SIMD run. The speed column is scalar/serial latency divided by
SIMD/parallel latency, so values above 1.00x mean the SIMD or parallel path is
faster.
| Stage | Dataset / mode | Baseline | SIMD / parallel | Speed |
|---|---|---|---|---|
| Search batch | uniform XYZ | Index3D 389.13 us |
SimdIndex3D 129.08 us |
3.01x |
| Search batch | flat Z | Index3D 1.8443 ms |
SimdIndex3D 1.1514 ms |
1.60x |
Build finish_simd |
uniform XYZ, 200k boxes | serial 10.632 ms | parallel 6.5412 ms | 1.63x |
The short version:
Index2Dis the general-purpose path;SimdIndex2DandSimdIndex3Dare best for heavier query batches where SIMD work amortizes well;- scalar
Index2Dsearch versusstatic_aabb2d_indexdepends on the generated data and query distribution, whileIndex2Dbuild is faster in these runs; Index3Dbuild and KNN are still slower thanIndex2D, but uniform 3D search can be faster when Z meaningfully prunes the tree;- SIMD persistence uses the same canonical bytes as scalar persistence; it pays an SoA gather/scatter cost but avoids a second file format;
anyis often much faster than collecting full result sets when all you need is existence;- AVX-512 is not always the fastest path in parallel workloads because CPU frequency behavior matters.
flatgeobuf2d_benchcompares against FlatGeobuf's packed Hilbert R-tree;index2d_benchcompares build/search paths againststatic_aabb2d_index;index3d_benchcovers 3D build/search/KNN, SIMD search/build, dimension comparisons, node sizes, and hidden Morton baseline;persistence_knn2d_benchcovers 2D scalar/SIMD persistence, loaded views, and KNN;persistence_knn3d_benchcovers 3D scalar/SIMD persistence, loaded views, and KNN.
Run the focused benchmark suites with:
Status
The core API is intentionally small and breaking API cleanup happens before a
1.0 release.
License
Licensed under the Apache License, Version 2.0.