Skip to content

Commit 2de0319

Browse files
perf(vortex-geo): short-circuit predicates with bounding boxes (#9076)
## Rationale for this change Constant-vs-column geometry predicates currently run the exact spatial predicate for every valid row, even when the constant and row bounding rectangles already prove that the result is false. For complex constant geometries and spatially selective queries, this repeats substantially more work than necessary. ## What changes are included in this PR? - Add an optional bounding-rectangle pre-check to the shared null-propagating binary geometry executor. - Short-circuit `vortex.geo.intersects` when operand rectangles are disjoint. - Short-circuit `vortex.geo.contains` when the container rectangle cannot cover the contained rectangle, preserving operand order. - Keep `vortex.geo.distance` on exact evaluation because rectangles cannot determine the result. - Preserve null propagation and fall back to exact evaluation for empty geometries or overlapping rectangles. - Add regression coverage for short-circuiting, nulls, operand order, empty geometries, exact-result equivalence, containment, and distance. - Add the `predicate_bbox` Divan microbenchmark. ### Microbenchmark Command: ```bash cargo bench -p vortex-geo --bench predicate_bbox -- --min-time 0.1 --sample-count 10 ``` The benchmark evaluates 8,192 points against a 128-vertex constant polygon. Median results: | Predicate / workload | Exact | Bounding-box pre-check | Change | | --- | ---: | ---: | ---: | | `contains`, disjoint | 689.0 us | 16.87 us | 40.8x faster | | `contains`, candidate | 674.4 us | 667.4 us | 1.0% faster | | `intersects`, disjoint | 670.5 us | 16.79 us | 39.9x faster | | `intersects`, candidate | 653.5 us | 670.4 us | 2.6% slower | --------- Signed-off-by: Nemo Yu <zyu379@wisc.edu>
1 parent 9ce11e4 commit 2de0319

8 files changed

Lines changed: 470 additions & 8 deletions

File tree

‎vortex-bench/src/spatialbench/benchmark.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ impl Benchmark for SpatialBenchBenchmark {
148148
// shorter vec means the runner skips them).
149149
match self.scale_factor.as_str() {
150150
"1.0" => Some(vec![0, 94, 1, 22, 258, 316691, 3, 6000000, 369, 37]),
151-
"10.0" => Some(vec![0, 994, 1, 79, 231, 3144328, 3, 60000000, 9357, 573]),
151+
"10.0" => Some(vec![0, 994, 1, 79, 243, 3144328, 3, 60000000, 9357, 573]),
152152
_ => None,
153153
}
154154
}

‎vortex-geo/Cargo.toml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,5 +45,9 @@ vortex-layout = { workspace = true }
4545
name = "envelope"
4646
harness = false
4747

48+
[[bench]]
49+
name = "predicate_bbox"
50+
harness = false
51+
4852
[lints]
4953
workspace = true
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// SPDX-FileCopyrightText: Copyright the Vortex contributors
3+
4+
//! Microbenchmark for the bounding-box pre-check used by constant-vs-column geometry predicates.
5+
//!
6+
//! `disjoint` rows are rejected by their bounding boxes, while `candidate` rows overlap the
7+
//! constant's box and therefore pay for both the pre-check and the exact predicate. Comparing the
8+
//! candidate cases tracks the worst-case overhead of the optimization.
9+
//!
10+
//! Run with `cargo bench -p vortex-geo --bench predicate_bbox`.
11+
12+
use std::f64::consts::TAU;
13+
use std::sync::LazyLock;
14+
15+
use divan::Bencher;
16+
use divan::counter::ItemsCount;
17+
use geo::BoundingRect;
18+
use geo::Contains;
19+
use geo::Intersects;
20+
use geo_types::Geometry;
21+
use geo_types::LineString;
22+
use geo_types::Point;
23+
use geo_types::Polygon;
24+
25+
fn main() {
26+
divan::main();
27+
}
28+
29+
const ROWS: usize = 1 << 13;
30+
const VERTICES: usize = 128;
31+
32+
static QUERY: LazyLock<Geometry<f64>> = LazyLock::new(|| {
33+
let mut ring: Vec<_> = (0..VERTICES)
34+
.map(|i| {
35+
let theta = TAU * i as f64 / VERTICES as f64;
36+
(10.0 * theta.cos(), 10.0 * theta.sin())
37+
})
38+
.collect();
39+
ring.push(ring[0]);
40+
Geometry::Polygon(Polygon::new(LineString::from(ring), vec![]))
41+
});
42+
43+
static DISJOINT: LazyLock<Vec<Geometry<f64>>> = LazyLock::new(|| {
44+
(0..ROWS)
45+
.map(|i| Geometry::Point(Point::new(100.0 + ordinate(i), 100.0 + ordinate(i + 1))))
46+
.collect()
47+
});
48+
49+
static CANDIDATES: LazyLock<Vec<Geometry<f64>>> = LazyLock::new(|| {
50+
(0..ROWS)
51+
.map(|i| {
52+
Geometry::Point(Point::new(
53+
20.0 * ordinate(i) - 10.0,
54+
20.0 * ordinate(i + 1) - 10.0,
55+
))
56+
})
57+
.collect()
58+
});
59+
60+
/// Deterministic pseudo-random value in `[0, 1)`.
61+
fn ordinate(i: usize) -> f64 {
62+
(i.wrapping_mul(2_654_435_761) % 10_000) as f64 / 10_000.0
63+
}
64+
65+
fn exact_intersects(query: &Geometry<f64>, rows: &[Geometry<f64>]) -> usize {
66+
rows.iter().filter(|row| query.intersects(*row)).count()
67+
}
68+
69+
fn bbox_intersects(query: &Geometry<f64>, rows: &[Geometry<f64>]) -> usize {
70+
let query_rect = query.bounding_rect();
71+
rows.iter()
72+
.filter(|row| {
73+
let rejected = query_rect
74+
.zip(row.bounding_rect())
75+
.is_some_and(|(query, row)| !query.intersects(&row));
76+
!rejected && query.intersects(*row)
77+
})
78+
.count()
79+
}
80+
81+
fn exact_contains(query: &Geometry<f64>, rows: &[Geometry<f64>]) -> usize {
82+
rows.iter().filter(|row| query.contains(*row)).count()
83+
}
84+
85+
fn bbox_contains(query: &Geometry<f64>, rows: &[Geometry<f64>]) -> usize {
86+
let query_rect = query.bounding_rect();
87+
rows.iter()
88+
.filter(|row| {
89+
let rejected = query_rect
90+
.zip(row.bounding_rect())
91+
.is_some_and(|(query, row)| !query.contains(&row));
92+
!rejected && query.contains(*row)
93+
})
94+
.count()
95+
}
96+
97+
#[divan::bench]
98+
fn intersects_exact_disjoint(bencher: Bencher) {
99+
bencher
100+
.counter(ItemsCount::new(ROWS))
101+
.with_inputs(|| (&*QUERY, &*DISJOINT))
102+
.bench_refs(|(query, rows)| exact_intersects(query, rows));
103+
}
104+
105+
#[divan::bench]
106+
fn intersects_bbox_disjoint(bencher: Bencher) {
107+
bencher
108+
.counter(ItemsCount::new(ROWS))
109+
.with_inputs(|| (&*QUERY, &*DISJOINT))
110+
.bench_refs(|(query, rows)| bbox_intersects(query, rows));
111+
}
112+
113+
#[divan::bench]
114+
fn intersects_exact_candidate(bencher: Bencher) {
115+
bencher
116+
.counter(ItemsCount::new(ROWS))
117+
.with_inputs(|| (&*QUERY, &*CANDIDATES))
118+
.bench_refs(|(query, rows)| exact_intersects(query, rows));
119+
}
120+
121+
#[divan::bench]
122+
fn intersects_bbox_candidate(bencher: Bencher) {
123+
bencher
124+
.counter(ItemsCount::new(ROWS))
125+
.with_inputs(|| (&*QUERY, &*CANDIDATES))
126+
.bench_refs(|(query, rows)| bbox_intersects(query, rows));
127+
}
128+
129+
#[divan::bench]
130+
fn contains_exact_disjoint(bencher: Bencher) {
131+
bencher
132+
.counter(ItemsCount::new(ROWS))
133+
.with_inputs(|| (&*QUERY, &*DISJOINT))
134+
.bench_refs(|(query, rows)| exact_contains(query, rows));
135+
}
136+
137+
#[divan::bench]
138+
fn contains_bbox_disjoint(bencher: Bencher) {
139+
bencher
140+
.counter(ItemsCount::new(ROWS))
141+
.with_inputs(|| (&*QUERY, &*DISJOINT))
142+
.bench_refs(|(query, rows)| bbox_contains(query, rows));
143+
}
144+
145+
#[divan::bench]
146+
fn contains_exact_candidate(bencher: Bencher) {
147+
bencher
148+
.counter(ItemsCount::new(ROWS))
149+
.with_inputs(|| (&*QUERY, &*CANDIDATES))
150+
.bench_refs(|(query, rows)| exact_contains(query, rows));
151+
}
152+
153+
#[divan::bench]
154+
fn contains_bbox_candidate(bencher: Bencher) {
155+
bencher
156+
.counter(ItemsCount::new(ROWS))
157+
.with_inputs(|| (&*QUERY, &*CANDIDATES))
158+
.bench_refs(|(query, rows)| bbox_contains(query, rows));
159+
}

‎vortex-geo/src/scalar_fn/contains.rs‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,16 @@ impl ScalarFnVTable for GeoContains {
8484
) -> VortexResult<ArrayRef> {
8585
let a = args.get(0)?;
8686
let b = args.get(1)?;
87-
// Containment is not symmetric: `a` is always the container and `b` the contained.
88-
execute_null_propagating(&a, &b, |a, b| a.contains(b), ctx)
87+
// Containment is not symmetric: `a` is always the container and `b` the contained. A
88+
// container's rect must cover the contained's rect (`Rect::contains` is the closed
89+
// test), so a contained rect poking outside proves the row false.
90+
execute_null_propagating(
91+
&a,
92+
&b,
93+
|a, b| a.contains(b),
94+
Some(|ra, rb| (!ra.contains(rb)).then_some(false)),
95+
ctx,
96+
)
8997
}
9098

9199
fn validity(
@@ -99,6 +107,10 @@ impl ScalarFnVTable for GeoContains {
99107
fn is_strict(&self, _: &Self::Options) -> bool {
100108
true
101109
}
110+
111+
fn is_fallible(&self, _: &Self::Options) -> bool {
112+
false
113+
}
102114
}
103115

104116
#[cfg(test)]
@@ -129,6 +141,7 @@ mod tests {
129141
use wkb::writer::WriteOptions;
130142

131143
use super::GeoContains;
144+
use crate::test_harness::linestring_column;
132145
use crate::test_harness::nullable_point_column;
133146
use crate::test_harness::point_column;
134147

@@ -234,6 +247,20 @@ mod tests {
234247
assert_contains(away, point, [false; 2])
235248
}
236249

250+
/// Constant container vs a linestring column: a row whose bounding rect pokes outside the
251+
/// container's rect is proven false by the rect pre-check alone; a fully inside row still
252+
/// needs (and passes) the exact test.
253+
#[test]
254+
fn constant_container_vs_row_rect_poking_outside() -> VortexResult<()> {
255+
let container = geometry_constant(&Geometry::Polygon(rect_polygon(0.0, 0.0, 4.0, 4.0)), 3)?;
256+
let lines = linestring_column(vec![
257+
vec![(1.0, 1.0), (3.0, 3.0)],
258+
vec![(1.0, 1.0), (9.0, 1.0)],
259+
vec![(5.0, 5.0), (9.0, 9.0)],
260+
])?;
261+
assert_contains(container, lines, [true, false, false])
262+
}
263+
237264
/// Column vs column pairs rows: each polygon row is tested against the point row at the
238265
/// same position.
239266
#[test]

‎vortex-geo/src/scalar_fn/distance.rs‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,8 @@ impl ScalarFnVTable for GeoDistance {
8585
) -> VortexResult<ArrayRef> {
8686
let a = args.get(0)?;
8787
let b = args.get(1)?;
88-
execute_null_propagating(&a, &b, |x, y| Euclidean.distance(x, y), ctx)
88+
// Distance is a value, not a verdict: no bounding-rect test can decide it.
89+
execute_null_propagating(&a, &b, |x, y| Euclidean.distance(x, y), None, ctx)
8990
}
9091

9192
fn validity(
@@ -99,6 +100,10 @@ impl ScalarFnVTable for GeoDistance {
99100
fn is_strict(&self, _: &Self::Options) -> bool {
100101
true
101102
}
103+
104+
fn is_fallible(&self, _: &Self::Options) -> bool {
105+
false
106+
}
102107
}
103108

104109
#[cfg(test)]
@@ -123,6 +128,7 @@ mod tests {
123128
use super::GeoDistance;
124129
use crate::test_harness::nullable_point_column;
125130
use crate::test_harness::point_column;
131+
use crate::test_harness::polygon_column;
126132

127133
/// A constant `Point` column of length `len`, every row at `(x, y)`.
128134
fn point_constant(
@@ -173,6 +179,23 @@ mod tests {
173179
Ok(())
174180
}
175181

182+
/// Distance passes no bounding-rect rejection: a point far outside a constant polygon's
183+
/// bounding rect still gets its true distance, alongside an inside point at distance zero.
184+
#[test]
185+
fn distance_to_constant_polygon_is_exact() -> VortexResult<()> {
186+
let session = vortex_array::array_session();
187+
let mut ctx = session.create_execution_ctx();
188+
189+
let ring = vec![(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0), (0.0, 0.0)];
190+
let single = polygon_column(vec![vec![ring]])?.execute_scalar(0, &mut ctx)?;
191+
let square = ConstantArray::new(single, 2).into_array();
192+
let points = point_column(vec![7.0, 2.0], vec![2.0, 2.0])?;
193+
let distance = GeoDistance::try_new_array(points, square)?.into_array();
194+
195+
assert_eq!(distances(distance, &mut ctx)?, vec![3.0, 0.0]);
196+
Ok(())
197+
}
198+
176199
/// The constant query point may be either operand; distance is symmetric.
177200
#[test]
178201
fn distance_with_constant_first_operand() -> VortexResult<()> {

‎vortex-geo/src/scalar_fn/envelope.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,10 @@ impl ScalarFnVTable for GeoEnvelope {
216216
fn is_strict(&self, _: &Self::Options) -> bool {
217217
true
218218
}
219+
220+
fn is_fallible(&self, _: &Self::Options) -> bool {
221+
false
222+
}
219223
}
220224

221225
#[cfg(test)]

0 commit comments

Comments
 (0)