Skip to content

Commit 5e8917c

Browse files
feat(vortex-geo): per-row bounding-box scalar function (vortex.geo.envelope) (#8831)
## What Adds `GeoEnvelope` (`vortex.geo.envelope`): the per-row 2-D axis-aligned bounding box of a native geometry column, returned as a native `geoarrow.box` (`Rect`) column. The consumer is a row-oriented spatial-join operator that bulk-loads an in-memory R-tree (`rstar::RTree::bulk_load` takes individual objects), so it needs one box per geometry, read back row by row. ## How - Walk the nested `ListView` storage down to the leaf coordinate `Struct`, tracking which top-level row owns each coordinate, then min/max x/y per row over the raw `f64` buffers. - A `Rect` input is the identity (the bounding box of a box is itself). - 2-D only: only the `x`/`y` leaf ordinates are read; any `z`/`m` are ignored — matching the `GeometryAabb` aggregate. - A null row, or a valid-but-empty geometry, yields a null box, so the output is always nullable. `validity()` returns `None`, since the output null mask is not derivable from the operand's validity (empty geometries introduce nulls even over a non-nullable operand). --------- Signed-off-by: Nemo Yu <zyu379@wisc.edu>
1 parent 69f5bc5 commit 5e8917c

11 files changed

Lines changed: 854 additions & 46 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

vortex-geo/Cargo.toml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,20 @@ vortex-mask = { workspace = true }
3030
vortex-session = { workspace = true }
3131
wkb = { workspace = true }
3232

33+
[features]
34+
# Exposes the `test_harness` module to benches; not part of the public API.
35+
_test-harness = []
36+
3337
[dev-dependencies]
38+
divan = { workspace = true }
3439
rstest = { workspace = true }
3540
vortex-array = { workspace = true, features = ["_test-harness"] }
41+
vortex-geo = { path = ".", features = ["_test-harness"] }
3642
vortex-layout = { workspace = true }
3743

44+
[[bench]]
45+
name = "envelope"
46+
harness = false
47+
3848
[lints]
3949
workspace = true

vortex-geo/benches/envelope.rs

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// SPDX-FileCopyrightText: Copyright the Vortex contributors
3+
4+
//! Microbenchmark for the `vortex.geo.envelope` scalar function: per-row bounding boxes over
5+
//! native geometry storage.
6+
//!
7+
//! Cases vary the two axes that dominate the kernel's cost profile:
8+
//! - nesting depth: `Point` (no `List` level, pure per-row reduction), `MultiPoint` (one level),
9+
//! `MultiPolygon` (three levels);
10+
//! - validity: non-nullable operands, predictable sparse nulls (~10%, periodic), and
11+
//! unpredictable dense nulls (~50%, pseudo-random) — the worst case for branching on validity.
12+
//!
13+
//! All cases share the same row count, so numbers are comparable across shapes.
14+
//!
15+
//! Run with `cargo bench -p vortex-geo --bench envelope`.
16+
17+
#![expect(clippy::unwrap_used)]
18+
19+
use std::sync::LazyLock;
20+
21+
use divan::Bencher;
22+
use divan::counter::ItemsCount;
23+
use vortex_array::ArrayRef;
24+
use vortex_array::Canonical;
25+
use vortex_array::ExecutionCtx;
26+
use vortex_array::IntoArray;
27+
use vortex_array::VortexSessionExecute;
28+
use vortex_geo::scalar_fn::envelope::GeoEnvelope;
29+
use vortex_geo::test_harness::MultiPolygonRings;
30+
use vortex_geo::test_harness::geo_session;
31+
use vortex_geo::test_harness::multipoint_column;
32+
use vortex_geo::test_harness::multipolygon_column;
33+
use vortex_geo::test_harness::nullable_multipolygon_column;
34+
use vortex_geo::test_harness::nullable_point_column;
35+
use vortex_geo::test_harness::point_column;
36+
use vortex_session::VortexSession;
37+
38+
fn main() {
39+
divan::main();
40+
}
41+
42+
static SESSION: LazyLock<VortexSession> = LazyLock::new(geo_session);
43+
44+
/// Every case has the same row count so results are comparable across shapes: differences then
45+
/// reflect per-row cost (nesting depth, validity handling) rather than input size.
46+
const ROWS: usize = 1 << 17;
47+
48+
/// Deterministic pseudo-random ordinate in `[0, 1000)`.
49+
fn ordinate(i: usize) -> f64 {
50+
(i.wrapping_mul(2654435761) % 1000) as f64
51+
}
52+
53+
/// A deterministic but unpredictable ~50% null pattern — the worst case for branching on
54+
/// validity, since the branch predictor cannot learn it (unlike a periodic `i % k` pattern).
55+
fn coin(i: usize) -> bool {
56+
(i.wrapping_mul(2654435761) >> 13) & 1 == 0
57+
}
58+
59+
/// Execute the envelope of `column` to completion.
60+
fn envelope(column: &ArrayRef, ctx: &mut ExecutionCtx) -> ArrayRef {
61+
GeoEnvelope::try_new_array(column.clone())
62+
.unwrap()
63+
.into_array()
64+
.execute::<Canonical>(ctx)
65+
.unwrap()
66+
.into_array()
67+
}
68+
69+
#[divan::bench]
70+
fn point_non_nullable(bencher: Bencher) {
71+
let xs = (0..ROWS).map(ordinate).collect();
72+
let ys = (0..ROWS).map(|i| ordinate(i + 1)).collect();
73+
let column = point_column(xs, ys).unwrap();
74+
let mut ctx = SESSION.create_execution_ctx();
75+
bencher
76+
.counter(ItemsCount::new(ROWS))
77+
.bench_local(|| envelope(&column, &mut ctx));
78+
}
79+
80+
#[divan::bench]
81+
fn point_mixed_validity(bencher: Bencher) {
82+
let points = (0..ROWS)
83+
.map(|i| (!i.is_multiple_of(10)).then(|| (ordinate(i), ordinate(i + 1))))
84+
.collect();
85+
let column = nullable_point_column(points).unwrap();
86+
let mut ctx = SESSION.create_execution_ctx();
87+
bencher
88+
.counter(ItemsCount::new(ROWS))
89+
.bench_local(|| envelope(&column, &mut ctx));
90+
}
91+
92+
#[divan::bench]
93+
fn point_random_nulls(bencher: Bencher) {
94+
let points = (0..ROWS)
95+
.map(|i| (!coin(i)).then(|| (ordinate(i), ordinate(i + 1))))
96+
.collect();
97+
let column = nullable_point_column(points).unwrap();
98+
let mut ctx = SESSION.create_execution_ctx();
99+
bencher
100+
.counter(ItemsCount::new(ROWS))
101+
.bench_local(|| envelope(&column, &mut ctx));
102+
}
103+
104+
const POINTS_PER_ROW: usize = 32;
105+
106+
#[divan::bench]
107+
fn multipoint_non_nullable(bencher: Bencher) {
108+
let rows = (0..ROWS)
109+
.map(|r| {
110+
(0..POINTS_PER_ROW)
111+
.map(|i| (ordinate(r + i), ordinate(r + i + 1)))
112+
.collect()
113+
})
114+
.collect();
115+
let column = multipoint_column(rows).unwrap();
116+
let mut ctx = SESSION.create_execution_ctx();
117+
bencher
118+
.counter(ItemsCount::new(ROWS))
119+
.bench_local(|| envelope(&column, &mut ctx));
120+
}
121+
122+
const VERTICES_PER_RING: usize = 8;
123+
124+
/// A multipolygon of two polygons with two rings each (8 vertices per ring, 32 per row), so the
125+
/// intermediate list levels have non-identity offsets.
126+
fn multipolygon_row(r: usize) -> MultiPolygonRings {
127+
let ring = |p: usize| {
128+
(0..VERTICES_PER_RING)
129+
.map(|i| (ordinate(r + p + i), ordinate(r + p + i + 1)))
130+
.collect()
131+
};
132+
vec![vec![ring(0), ring(1)], vec![ring(2), ring(3)]]
133+
}
134+
135+
#[divan::bench]
136+
fn multipolygon_non_nullable(bencher: Bencher) {
137+
let rows = (0..ROWS).map(multipolygon_row).collect();
138+
let column = multipolygon_column(rows).unwrap();
139+
let mut ctx = SESSION.create_execution_ctx();
140+
bencher
141+
.counter(ItemsCount::new(ROWS))
142+
.bench_local(|| envelope(&column, &mut ctx));
143+
}
144+
145+
#[divan::bench]
146+
fn multipolygon_mixed_validity(bencher: Bencher) {
147+
let rows = (0..ROWS)
148+
.map(|r| (!r.is_multiple_of(10)).then(|| multipolygon_row(r)))
149+
.collect();
150+
let column = nullable_multipolygon_column(rows).unwrap();
151+
let mut ctx = SESSION.create_execution_ctx();
152+
bencher
153+
.counter(ItemsCount::new(ROWS))
154+
.bench_local(|| envelope(&column, &mut ctx));
155+
}
156+
157+
#[divan::bench]
158+
fn multipolygon_random_nulls(bencher: Bencher) {
159+
let rows = (0..ROWS)
160+
.map(|r| (!coin(r)).then(|| multipolygon_row(r)))
161+
.collect();
162+
let column = nullable_multipolygon_column(rows).unwrap();
163+
let mut ctx = SESSION.create_execution_ctx();
164+
bencher
165+
.counter(ItemsCount::new(ROWS))
166+
.bench_local(|| envelope(&column, &mut ctx));
167+
}

vortex-geo/src/aggregate_fn/aabb.rs

Lines changed: 9 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,6 @@ use vortex_array::aggregate_fn::AggregateFnRef;
1313
use vortex_array::aggregate_fn::AggregateFnVTable;
1414
use vortex_array::aggregate_fn::AggregateFnVTableExt;
1515
use vortex_array::aggregate_fn::EmptyOptions;
16-
use vortex_array::arrays::PrimitiveArray;
17-
use vortex_array::arrays::struct_::StructArrayExt;
1816
use vortex_array::dtype::DType;
1917
use vortex_array::dtype::Nullability;
2018
use vortex_array::dtype::extension::ExtDType;
@@ -29,6 +27,8 @@ use crate::extension::GeoMetadata;
2927
use crate::extension::Rect;
3028
use crate::extension::box_storage_dtype;
3129
use crate::extension::coordinate::Dimension;
30+
use crate::extension::coordinate::box_corners;
31+
use crate::extension::coordinate::ordinates;
3232
use crate::extension::flatten_coordinates;
3333
use crate::extension::is_native_geometry;
3434

@@ -78,18 +78,10 @@ fn aabb_storage_dtype() -> DType {
7878

7979
/// The AABB of the raw `x`/`y` slices, or `None` when empty.
8080
fn aabb_of(xs: &[f64], ys: &[f64]) -> Option<GeoRect<f64>> {
81-
if xs.is_empty() {
82-
return None;
83-
}
84-
let min_max = |vals: &[f64]| {
85-
vals.iter()
86-
.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), &v| {
87-
(lo.min(v), hi.max(v))
88-
})
89-
};
90-
let (xmin, xmax) = min_max(xs);
91-
let (ymin, ymax) = min_max(ys);
92-
Some(GeoRect::new((xmin, ymin), (xmax, ymax)))
81+
(!xs.is_empty()).then(|| {
82+
let [xmin, ymin, xmax, ymax] = box_corners(xs, ys);
83+
GeoRect::new((xmin, ymin), (xmax, ymax))
84+
})
9385
}
9486

9587
/// Read an AABB stat scalar (a nullable native `geoarrow.box`) into a [`GeoRect`], or `None` when
@@ -217,15 +209,9 @@ impl AggregateFnVTable for GeometryAabb {
217209
// `unmasked_field_by_name` reads are therefore safe. Min/max the raw x/y buffers directly:
218210
// cheap, and avoids `to_geometry`'s panic on empty points (which decoding would hit).
219211
let coords = flatten_coordinates(&array, ctx)?;
220-
let xs = coords
221-
.unmasked_field_by_name("x")?
222-
.clone()
223-
.execute::<PrimitiveArray>(ctx)?;
224-
let ys = coords
225-
.unmasked_field_by_name("y")?
226-
.clone()
227-
.execute::<PrimitiveArray>(ctx)?;
228-
if let Some(rect) = aabb_of(xs.as_slice::<f64>(), ys.as_slice::<f64>()) {
212+
let xs = ordinates(&coords, "x", ctx)?;
213+
let ys = ordinates(&coords, "y", ctx)?;
214+
if let Some(rect) = aabb_of(&xs, &ys) {
229215
partial.merge(rect);
230216
}
231217
Ok(())

vortex-geo/src/extension/coordinate.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,16 @@ use std::fmt::Display;
1515
use std::fmt::Formatter;
1616

1717
use geoarrow::datatypes::Dimension as GeoArrowDimension;
18+
use vortex_array::ExecutionCtx;
19+
use vortex_array::arrays::StructArray;
20+
use vortex_array::arrays::struct_::StructArrayExt;
1821
use vortex_array::dtype::DType;
1922
use vortex_array::dtype::FieldNames;
2023
use vortex_array::dtype::Nullability;
2124
use vortex_array::dtype::PType;
2225
use vortex_array::dtype::StructFields;
2326
use vortex_array::scalar::Scalar;
27+
use vortex_buffer::Buffer;
2428
use vortex_error::VortexResult;
2529
use vortex_error::vortex_bail;
2630
use vortex_error::vortex_ensure;
@@ -189,6 +193,33 @@ pub(crate) fn coordinate_from_struct(scalar: &Scalar) -> VortexResult<Coordinate
189193
})
190194
}
191195

196+
/// Materialize the named ordinate (`x`, `y`, ...) of a coordinate `Struct` column as a flat
197+
/// [`Buffer`] for bulk reads.
198+
pub(crate) fn ordinates(
199+
coords: &StructArray,
200+
name: &str,
201+
ctx: &mut ExecutionCtx,
202+
) -> VortexResult<Buffer<f64>> {
203+
coords
204+
.unmasked_field_by_name(name)?
205+
.clone()
206+
.execute::<Buffer<f64>>(ctx)
207+
}
208+
209+
/// The corners of the box containing the `(xs, ys)` coordinates, in
210+
/// `[xmin, ymin, xmax, ymax]` order.
211+
pub(crate) fn box_corners(xs: &[f64], ys: &[f64]) -> [f64; 4] {
212+
let (mut xmin, mut ymin) = (f64::INFINITY, f64::INFINITY);
213+
let (mut xmax, mut ymax) = (f64::NEG_INFINITY, f64::NEG_INFINITY);
214+
for (&x, &y) in xs.iter().zip(ys) {
215+
xmin = xmin.min(x);
216+
ymin = ymin.min(y);
217+
xmax = xmax.max(x);
218+
ymax = ymax.max(y);
219+
}
220+
[xmin, ymin, xmax, ymax]
221+
}
222+
192223
#[cfg(test)]
193224
mod tests {
194225
use rstest::rstest;

0 commit comments

Comments
 (0)