diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml index d27a977e11a..68d8f644e9d 100644 --- a/.github/workflows/codspeed.yml +++ b/.github/workflows/codspeed.yml @@ -57,6 +57,7 @@ jobs: - { shard: 6, name: "Encodings 3", packages: "vortex-pco vortex-runend vortex-sequence" } - { shard: 7, name: "Encodings 4", packages: "vortex-sparse vortex-zigzag vortex-zstd" } - { shard: 8, name: "Storage formats & row encoding", packages: "vortex-flatbuffers vortex-proto vortex-btrblocks vortex-row" } + - { shard: 9, name: "Tensor & geo", packages: "vortex-tensor vortex-geo" } name: "Benchmark with Codspeed (Shard #${{ matrix.shard }})" timeout-minutes: 30 runs-on: >- diff --git a/Cargo.lock b/Cargo.lock index db19fe686e9..65c8d93cc72 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10073,6 +10073,7 @@ dependencies = [ "geo-types", "geoarrow", "geoarrow-cast", + "mimalloc", "prost 0.14.4", "rstest", "vortex-array", @@ -10486,8 +10487,10 @@ version = "0.1.0" dependencies = [ "arrow-array 58.4.0", "arrow-schema 58.4.0", + "codspeed-divan-compat", "half", "itertools 0.14.0", + "mimalloc", "num-traits", "prost 0.14.4", "rstest", diff --git a/vortex-array/Cargo.toml b/vortex-array/Cargo.toml index 70fba4acdcf..4bcab93198a 100644 --- a/vortex-array/Cargo.toml +++ b/vortex-array/Cargo.toml @@ -137,6 +137,10 @@ harness = false name = "like" harness = false +[[bench]] +name = "byte_length" +harness = false + [[bench]] name = "interleave" harness = false diff --git a/vortex-array/benches/binary_ops.rs b/vortex-array/benches/binary_ops.rs index f3792e22990..00ad82d3457 100644 --- a/vortex-array/benches/binary_ops.rs +++ b/vortex-array/benches/binary_ops.rs @@ -54,6 +54,30 @@ fn add_i64_nullable(bencher: Bencher) { bench_primitive(bencher, lhs, rhs, Operator::Add); } +#[divan::bench] +fn add_i64_constant(bencher: Bencher) { + let lhs = primitive_nonnull(0).into_array(); + let rhs = ConstantArray::new(1_000_000i64, LEN).into_array(); + + bench_primitive(bencher, lhs, rhs, Operator::Add); +} + +#[divan::bench] +fn add_i32_nonnull(bencher: Bencher) { + let lhs = primitive_i32_small_nonnull(1).into_array(); + let rhs = primitive_i32_small_nonnull(17).into_array(); + + bench_primitive(bencher, lhs, rhs, Operator::Add); +} + +#[divan::bench] +fn add_u32_nonnull(bencher: Bencher) { + let lhs = primitive_u32_small_nonnull(1).into_array(); + let rhs = primitive_u32_small_nonnull(17).into_array(); + + bench_primitive(bencher, lhs, rhs, Operator::Add); +} + #[divan::bench] fn mul_i64_nonnull(bencher: Bencher) { let lhs = primitive_small_nonnull(1).into_array(); diff --git a/vortex-array/benches/byte_length.rs b/vortex-array/benches/byte_length.rs new file mode 100644 index 00000000000..e64cb98b562 --- /dev/null +++ b/vortex-array/benches/byte_length.rs @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Baseline throughput for `byte_length` over UTF-8 view arrays. +//! +//! The arms cover inline and out-of-line views, plus nullable out-of-line views. Their names are +//! intended to remain stable across scalar-function implementation changes so CodSpeed can compare +//! them against `develop`. + +#![expect(clippy::unwrap_used)] + +use std::sync::LazyLock; + +use divan::Bencher; +use divan::counter::ItemsCount; +use mimalloc::MiMalloc; +use vortex_array::ArrayRef; +use vortex_array::Canonical; +use vortex_array::IntoArray; +use vortex_array::VortexSessionExecute; +use vortex_array::array_session; +use vortex_array::arrays::MaskedArray; +use vortex_array::arrays::ScalarFnArray; +use vortex_array::arrays::VarBinViewArray; +use vortex_array::scalar_fn::EmptyOptions; +use vortex_array::scalar_fn::TypedScalarFnInstance; +use vortex_array::scalar_fn::fns::byte_length::ByteLength; +use vortex_array::validity::Validity; +use vortex_session::VortexSession; + +// Scalar function execution allocates its output inside the timed region, so use the vendored +// allocator instead of measuring glibc differences between CodSpeed runner images. +#[global_allocator] +static GLOBAL: MiMalloc = MiMalloc; + +static SESSION: LazyLock = LazyLock::new(array_session); + +fn main() { + LazyLock::force(&SESSION); + divan::main(); +} + +const SIZES: &[usize] = &[4_096, 65_536]; + +fn long_strings(len: usize) -> ArrayRef { + VarBinViewArray::from_iter_str((0..len).map(|i| format!("a string well past inline: {i}"))) + .into_array() +} + +fn short_strings(len: usize) -> ArrayRef { + VarBinViewArray::from_iter_str((0..len).map(|i| format!("{}", i % 1_000))).into_array() +} + +fn bench_byte_length(bencher: Bencher, input: ArrayRef) { + let len = input.len(); + bencher + .counter(ItemsCount::new(len)) + .with_inputs(|| { + ( + ScalarFnArray::try_new( + TypedScalarFnInstance::new(ByteLength, EmptyOptions).erased(), + vec![input.clone()], + ) + .unwrap() + .into_array(), + SESSION.create_execution_ctx(), + ) + }) + .bench_values(|(array, mut ctx)| array.execute::(&mut ctx).unwrap()); +} + +#[divan::bench(args = SIZES)] +fn inline(bencher: Bencher, len: usize) { + bench_byte_length(bencher, short_strings(len)); +} + +#[divan::bench(args = SIZES)] +fn out_of_line(bencher: Bencher, len: usize) { + bench_byte_length(bencher, long_strings(len)); +} + +#[divan::bench(args = SIZES)] +fn nullable_out_of_line(bencher: Bencher, len: usize) { + let validity = Validity::from_iter((0..len).map(|i| i % 8 != 0)); + let input = MaskedArray::try_new(long_strings(len), validity) + .unwrap() + .into_array(); + bench_byte_length(bencher, input); +} + +#[divan::bench(args = SIZES)] +fn nullable_out_of_line_90pct(bencher: Bencher, len: usize) { + let validity = Validity::from_iter((0..len).map(|i| i.is_multiple_of(10))); + let input = MaskedArray::try_new(long_strings(len), validity) + .unwrap() + .into_array(); + bench_byte_length(bencher, input); +} diff --git a/vortex-array/benches/like.rs b/vortex-array/benches/like.rs index 68219724717..321074b80df 100644 --- a/vortex-array/benches/like.rs +++ b/vortex-array/benches/like.rs @@ -13,8 +13,9 @@ use vortex_array::IntoArray; use vortex_array::VortexSessionExecute; use vortex_array::arrays::BoolArray; use vortex_array::arrays::ConstantArray; +use vortex_array::arrays::ScalarFnArray; use vortex_array::arrays::VarBinViewArray; -use vortex_array::arrays::scalar_fn::ScalarFnFactoryExt; +use vortex_array::scalar_fn::TypedScalarFnInstance; use vortex_array::scalar_fn::fns::like::Like; use vortex_array::scalar_fn::fns::like::LikeOptions; @@ -47,15 +48,15 @@ fn bench_like(bencher: Bencher, pattern: &str, options: LikeOptions) { bencher .with_inputs(|| { ( - Like.try_new_array( - ARRAY_SIZE, - options, - [ + ScalarFnArray::try_new( + TypedScalarFnInstance::new(Like, options).erased(), + vec![ array.clone(), ConstantArray::new(pattern, ARRAY_SIZE).into_array(), ], ) - .unwrap(), + .unwrap() + .into_array(), session.create_execution_ctx(), ) }) @@ -87,28 +88,61 @@ fn like_regex(bencher: Bencher) { bench_like(bencher, "h_llo%w%d", LikeOptions::default()); } -#[divan::bench] -fn like_per_row_patterns(bencher: Bencher) { +fn bench_per_row_patterns(bencher: Bencher, patterns: ArrayRef) { let session = vortex_array::array_session(); let array = strings(); - // A non-constant pattern child takes the per-row path; repeated patterns hit the - // compile cache. - let patterns = VarBinViewArray::from_iter_str((0..ARRAY_SIZE).map(|_| "hello%")).into_array(); bencher .with_inputs(|| { ( - Like.try_new_array( - ARRAY_SIZE, - LikeOptions::default(), - [array.clone(), patterns.clone()], + ScalarFnArray::try_new( + TypedScalarFnInstance::new(Like, LikeOptions::default()).erased(), + vec![array.clone(), patterns.clone()], ) - .unwrap(), + .unwrap() + .into_array(), session.create_execution_ctx(), ) }) .bench_values(|(array, mut ctx)| array.execute::(&mut ctx).unwrap()); } +#[divan::bench] +fn like_per_row_patterns(bencher: Bencher) { + // A non-constant pattern child takes the per-row path; repeated patterns hit the compile cache. + let patterns = VarBinViewArray::from_iter_str((0..ARRAY_SIZE).map(|_| "hello%")).into_array(); + bench_per_row_patterns(bencher, patterns); +} + +/// The cached half of the compile-cache pair: every row repeats one pattern whose five-byte shape +/// matches the distinct-pattern arm, so the two differ only in whether the cache hits. +/// +/// [`like_per_row_distinct_patterns`] +#[divan::bench] +fn like_per_row_repeated_patterns(bencher: Bencher) { + let patterns = VarBinViewArray::from_iter_str((0..ARRAY_SIZE).map(|_| "%aaa%")).into_array(); + bench_per_row_patterns(bencher, patterns); +} + +/// The per-row path with the compile cache defeated: every row carries a distinct pattern of the +/// same shape as the repeated-pattern arm, so each row pays one pattern compilation. +/// +/// [`like_per_row_repeated_patterns`] +#[divan::bench] +fn like_per_row_distinct_patterns(bencher: Bencher) { + let patterns = VarBinViewArray::from_iter_str( + (0..ARRAY_SIZE).map(|i| format!("%{}%", distinct_trigram(i))), + ) + .into_array(); + bench_per_row_patterns(bencher, patterns); +} + +/// A distinct three-letter lowercase infix per row, so every pattern has the same shape while +/// no two rows share a compiled pattern. +fn distinct_trigram(i: usize) -> String { + let letter = |shift: usize| char::from(b'a' + u8::try_from((i >> shift) % 26).unwrap()); + [letter(0), letter(5), letter(10)].iter().collect() +} + #[divan::bench] fn ilike_contains(bencher: Bencher) { bench_like( diff --git a/vortex-geo/Cargo.toml b/vortex-geo/Cargo.toml index fcd13ffa641..6eddab411d9 100644 --- a/vortex-geo/Cargo.toml +++ b/vortex-geo/Cargo.toml @@ -36,6 +36,7 @@ _test-harness = [] [dev-dependencies] divan = { workspace = true } +mimalloc = { workspace = true } rstest = { workspace = true } vortex-array = { workspace = true, features = ["_test-harness"] } vortex-geo = { path = ".", features = ["_test-harness"] } @@ -49,5 +50,13 @@ harness = false name = "predicate_bbox" harness = false +[[bench]] +name = "binary_predicates" +harness = false + +[[bench]] +name = "distance" +harness = false + [lints] workspace = true diff --git a/vortex-geo/benches/binary_predicates.rs b/vortex-geo/benches/binary_predicates.rs new file mode 100644 index 00000000000..1829a02b72e --- /dev/null +++ b/vortex-geo/benches/binary_predicates.rs @@ -0,0 +1,393 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Microbenchmarks for the binary geometry predicates `ST_Contains` and `ST_Intersects`, focused +//! on the cost of a batch-constant operand. +//! +//! The constant is a 128-vertex query polygon, the shape a spatial filter broadcasts against a +//! column. Arms pair it with a point column (geo answers those pairings with direct +//! point-in-polygon algorithms) and with a small-polygon column (geo routes those pairings through +//! bounding-box prechecks and `relate`), split into a mostly-disjoint and a mostly-overlapping +//! dataset so the bbox early-out's win and its overhead are both visible. The column-x-column arms +//! are the control: no operand is constant, so a prepared path has nothing to hoist and must not +//! regress them. +//! +//! Run with `cargo bench -p vortex-geo --bench binary_predicates`. + +#![expect(clippy::unwrap_used)] + +use std::f64::consts::TAU; +use std::sync::LazyLock; + +use divan::Bencher; +use divan::counter::ItemsCount; +use mimalloc::MiMalloc; +use vortex_array::ArrayRef; +use vortex_array::Canonical; +use vortex_array::ExecutionCtx; +use vortex_array::IntoArray; +use vortex_array::VortexSessionExecute; +use vortex_array::arrays::ConstantArray; +use vortex_array::arrays::MaskedArray; +use vortex_array::validity::Validity; +use vortex_error::VortexResult; +use vortex_geo::scalar_fn::contains::GeoContains; +use vortex_geo::scalar_fn::intersects::GeoIntersects; +use vortex_geo::test_harness::geo_session; +use vortex_geo::test_harness::point_column; +use vortex_geo::test_harness::polygon_column; +use vortex_session::VortexSession; + +// Scalar function execution allocates its output inside the timed region, so use the vendored +// allocator instead of measuring glibc differences between CodSpeed runner images. +#[global_allocator] +static GLOBAL: MiMalloc = MiMalloc; + +fn main() { + divan::main(); +} + +static SESSION: LazyLock = LazyLock::new(geo_session); + +/// The ordinary arms use the same row count so results are comparable across shapes. +const ROWS: usize = 1 << 14; + +/// The overlapping-polygon arm pays for topology graphs on every row. Keep its CodSpeed CPU +/// simulation workload bounded without shrinking the rest of the production baselines. +const OVERLAPPING_POLYGON_ROWS: usize = 1 << 10; + +/// Deterministic pseudo-random value in `[0, 1)`. +fn unit(i: usize) -> f64 { + ((i.wrapping_mul(2654435761) >> 8) % 10_000) as f64 / 10_000.0 +} + +/// The exterior ring of a convex 128-gon of radius 100 centered at `(cx, cy)`: enough vertices +/// that per-row work proportional to the constant's size shows up clearly. +fn query_ring(cx: f64, cy: f64) -> Vec<(f64, f64)> { + let n = 128; + (0..=n) + .map(|i| { + let theta = (i % n) as f64 / n as f64 * TAU; + (cx + 100.0 * theta.cos(), cy + 100.0 * theta.sin()) + }) + .collect() +} + +/// The query polygon as a batch-constant operand: a top-level `ConstantArray` over the geometry +/// extension scalar, the shape that reaches the row loop's stride-0 path. +fn query_constant(ctx: &mut ExecutionCtx, rows: usize) -> ArrayRef { + let scalar = polygon_column(vec![vec![query_ring(0.0, 0.0)]]) + .unwrap() + .execute_scalar(0, ctx) + .unwrap(); + ConstantArray::new(scalar, rows).into_array() +} + +/// A batch-constant point operand with the requested row count. +fn point_constant(ctx: &mut ExecutionCtx, rows: usize) -> ArrayRef { + let scalar = point_column(vec![0.0], vec![0.0]) + .unwrap() + .execute_scalar(0, ctx) + .unwrap(); + ConstantArray::new(scalar, rows).into_array() +} + +/// A small square (side 2) centered at `(cx, cy)`. +fn square(cx: f64, cy: f64) -> Vec> { + vec![vec![ + (cx - 1.0, cy - 1.0), + (cx + 1.0, cy - 1.0), + (cx + 1.0, cy + 1.0), + (cx - 1.0, cy + 1.0), + (cx - 1.0, cy - 1.0), + ]] +} + +/// `rows` small squares whose centers avoid the query polygon almost always: the shape of a +/// selective spatial filter, where a bbox check rejects nearly every row. +fn squares_mostly_disjoint(rows: usize) -> ArrayRef { + let rows = (0..rows) + .map(|i| square(150.0 + 700.0 * unit(i), 150.0 + 700.0 * unit(i + 1))) + .collect(); + polygon_column(rows).unwrap() +} + +/// `rows` small squares whose centers all fall well inside the query polygon, so a bbox check +/// never rejects and the full pairwise predicate always runs. +fn squares_mostly_overlapping(rows: usize) -> ArrayRef { + let rows = (0..rows) + .map(|i| square(120.0 * unit(i) - 60.0, 120.0 * unit(i + 1) - 60.0)) + .collect(); + polygon_column(rows).unwrap() +} + +/// `rows` points spread over `[-150, 150)^2`, mixing rows inside and outside the query polygon. +fn points(rows: usize) -> ArrayRef { + let xs = (0..rows).map(|i| 300.0 * unit(i) - 150.0).collect(); + let ys = (0..rows).map(|i| 300.0 * unit(i + 1) - 150.0).collect(); + point_column(xs, ys).unwrap() +} + +/// Execute `array` to completion. +fn execute(array: VortexResult, ctx: &mut ExecutionCtx) -> ArrayRef { + array + .unwrap() + .into_array() + .execute::(ctx) + .unwrap() + .into_array() +} + +/// Marks one row in `null_every` null without changing the geometry storage. +fn nullable_every(array: ArrayRef, null_every: usize) -> ArrayRef { + let validity = Validity::from_iter((0..array.len()).map(|i| !i.is_multiple_of(null_every))); + MaskedArray::try_new(array, validity).unwrap().into_array() +} + +/// A deterministic 90%-null validity pattern. +fn ninety_percent_null(array: ArrayRef) -> ArrayRef { + let validity = Validity::from_iter((0..array.len()).map(|i| i.is_multiple_of(10))); + MaskedArray::try_new(array, validity).unwrap().into_array() +} + +/// A deterministic 50% validity pattern with a distinct phase per operand. Adjacent phases make +/// the two columns jointly cover every valid/null combination once per four rows. +fn half_valid(array: ArrayRef, phase: usize) -> ArrayRef { + let validity = Validity::from_iter((0..array.len()).map(|i| (i + phase) % 4 < 2)); + MaskedArray::try_new(array, validity).unwrap().into_array() +} + +mod contains { + use super::*; + + /// Control: no constant operand, direct point-in-polygon per row. + #[divan::bench] + fn column_x_column_points(bencher: Bencher) { + let mut ctx = SESSION.create_execution_ctx(); + let polygons = squares_mostly_overlapping(ROWS); + let points = points(ROWS); + bencher.counter(ItemsCount::new(ROWS)).bench_local(|| { + execute( + GeoContains::try_new_array(polygons.clone(), points.clone()), + &mut ctx, + ) + }); + } + + /// Control: no constant operand, relate-routed polygon pairs per row. + #[divan::bench] + fn column_x_column_polygons(bencher: Bencher) { + let mut ctx = SESSION.create_execution_ctx(); + let a = squares_mostly_overlapping(ROWS); + let b = squares_mostly_disjoint(ROWS); + bencher + .counter(ItemsCount::new(ROWS)) + .bench_local(|| execute(GeoContains::try_new_array(a.clone(), b.clone()), &mut ctx)); + } + + /// Constant container against a point column: geo's direct point-in-polygon pairing. + #[divan::bench] + fn constant_x_points(bencher: Bencher) { + let mut ctx = SESSION.create_execution_ctx(); + let query = query_constant(&mut ctx, ROWS); + let points = points(ROWS); + bencher.counter(ItemsCount::new(ROWS)).bench_local(|| { + execute( + GeoContains::try_new_array(query.clone(), points.clone()), + &mut ctx, + ) + }); + } + + /// Constant container against mostly-disjoint polygons: relate-routed, and almost every row + /// short-circuits on bounding boxes inside relate. + #[divan::bench] + fn constant_x_polygons_disjoint(bencher: Bencher) { + let mut ctx = SESSION.create_execution_ctx(); + let query = query_constant(&mut ctx, ROWS); + let polygons = squares_mostly_disjoint(ROWS); + bencher.counter(ItemsCount::new(ROWS)).bench_local(|| { + execute( + GeoContains::try_new_array(query.clone(), polygons.clone()), + &mut ctx, + ) + }); + } + + /// Constant container against overlapping polygons. Each row reaches the full topology path, + /// so this arm uses a smaller fixture while the ordinary geo baselines remain at [`ROWS`]. + #[divan::bench] + fn constant_x_polygons_overlapping(bencher: Bencher) { + let mut ctx = SESSION.create_execution_ctx(); + let query = query_constant(&mut ctx, OVERLAPPING_POLYGON_ROWS); + let polygons = squares_mostly_overlapping(OVERLAPPING_POLYGON_ROWS); + bencher + .counter(ItemsCount::new(OVERLAPPING_POLYGON_ROWS)) + .bench_local(|| { + execute( + GeoContains::try_new_array(query.clone(), polygons.clone()), + &mut ctx, + ) + }); + } + + /// Constant container against a point column with one null row in eight. + #[divan::bench] + fn constant_x_nullable_points(bencher: Bencher) { + let mut ctx = SESSION.create_execution_ctx(); + let query = query_constant(&mut ctx, ROWS); + let points = nullable_every(points(ROWS), 8); + bencher.counter(ItemsCount::new(ROWS)).bench_local(|| { + execute( + GeoContains::try_new_array(query.clone(), points.clone()), + &mut ctx, + ) + }); + } + + /// Constant container against mostly-disjoint polygons with one null row in eight. + #[divan::bench] + fn constant_x_nullable_polygons_disjoint(bencher: Bencher) { + let mut ctx = SESSION.create_execution_ctx(); + let query = query_constant(&mut ctx, ROWS); + let polygons = nullable_every(squares_mostly_disjoint(ROWS), 8); + bencher.counter(ItemsCount::new(ROWS)).bench_local(|| { + execute( + GeoContains::try_new_array(query.clone(), polygons.clone()), + &mut ctx, + ) + }); + } + + /// Polygon columns against a constant point exercise the direct geometry pairing without a + /// constant container. + #[divan::bench] + fn polygons_x_constant_point(bencher: Bencher) { + let mut ctx = SESSION.create_execution_ctx(); + let polygons = squares_mostly_overlapping(ROWS); + let point = point_constant(&mut ctx, ROWS); + bencher.counter(ItemsCount::new(ROWS)).bench_local(|| { + execute( + GeoContains::try_new_array(polygons.clone(), point.clone()), + &mut ctx, + ) + }); + } + + /// The same polygon-x-constant-point pairing with a deterministic 90% null polygon column. + #[divan::bench] + fn nullable_polygons_90pct_x_constant_point(bencher: Bencher) { + let mut ctx = SESSION.create_execution_ctx(); + let polygons = ninety_percent_null(squares_mostly_overlapping(ROWS)); + let point = point_constant(&mut ctx, ROWS); + bencher.counter(ItemsCount::new(ROWS)).bench_local(|| { + execute( + GeoContains::try_new_array(polygons.clone(), point.clone()), + &mut ctx, + ) + }); + } + + /// Independently 50%-valid polygon and point columns cover mixed validity without test-only + /// execution controls. + #[divan::bench] + fn nullable_polygons_x_nullable_points(bencher: Bencher) { + let mut ctx = SESSION.create_execution_ctx(); + let polygons = half_valid(squares_mostly_overlapping(ROWS), 0); + let points = half_valid(points(ROWS), 1); + bencher.counter(ItemsCount::new(ROWS)).bench_local(|| { + execute( + GeoContains::try_new_array(polygons.clone(), points.clone()), + &mut ctx, + ) + }); + } +} + +mod intersects { + use super::*; + + /// Control: no constant operand, polygon pairs per row. + #[divan::bench] + fn column_x_column_polygons(bencher: Bencher) { + let mut ctx = SESSION.create_execution_ctx(); + let a = squares_mostly_overlapping(ROWS); + let b = squares_mostly_disjoint(ROWS); + bencher + .counter(ItemsCount::new(ROWS)) + .bench_local(|| execute(GeoIntersects::try_new_array(a.clone(), b.clone()), &mut ctx)); + } + + /// Point column against the constant query: geo answers point-x-polygon directly, with no + /// bbox precheck to hoist. + #[divan::bench] + fn points_x_constant(bencher: Bencher) { + let mut ctx = SESSION.create_execution_ctx(); + let points = points(ROWS); + let query = query_constant(&mut ctx, ROWS); + bencher.counter(ItemsCount::new(ROWS)).bench_local(|| { + execute( + GeoIntersects::try_new_array(points.clone(), query.clone()), + &mut ctx, + ) + }); + } + + /// Mostly-disjoint polygons against the constant query: the bbox precheck rejects nearly + /// every row, so the constant's per-row bounding-box fold dominates the baseline. + #[divan::bench] + fn polygons_disjoint_x_constant(bencher: Bencher) { + let mut ctx = SESSION.create_execution_ctx(); + let polygons = squares_mostly_disjoint(ROWS); + let query = query_constant(&mut ctx, ROWS); + bencher.counter(ItemsCount::new(ROWS)).bench_local(|| { + execute( + GeoIntersects::try_new_array(polygons.clone(), query.clone()), + &mut ctx, + ) + }); + } + + /// Mostly-overlapping polygons against the constant query: the bbox precheck never rejects, + /// so every row still pays for the full pairwise predicate. + #[divan::bench] + fn polygons_overlapping_x_constant(bencher: Bencher) { + let mut ctx = SESSION.create_execution_ctx(); + let polygons = squares_mostly_overlapping(ROWS); + let query = query_constant(&mut ctx, ROWS); + bencher.counter(ItemsCount::new(ROWS)).bench_local(|| { + execute( + GeoIntersects::try_new_array(polygons.clone(), query.clone()), + &mut ctx, + ) + }); + } + + /// Nullable point column against the constant query, with one null row in eight. + #[divan::bench] + fn nullable_points_x_constant(bencher: Bencher) { + let mut ctx = SESSION.create_execution_ctx(); + let points = nullable_every(points(ROWS), 8); + let query = query_constant(&mut ctx, ROWS); + bencher.counter(ItemsCount::new(ROWS)).bench_local(|| { + execute( + GeoIntersects::try_new_array(points.clone(), query.clone()), + &mut ctx, + ) + }); + } + + /// Mostly-disjoint nullable polygons against the constant query. + #[divan::bench] + fn nullable_polygons_disjoint_x_constant(bencher: Bencher) { + let mut ctx = SESSION.create_execution_ctx(); + let polygons = nullable_every(squares_mostly_disjoint(ROWS), 8); + let query = query_constant(&mut ctx, ROWS); + bencher.counter(ItemsCount::new(ROWS)).bench_local(|| { + execute( + GeoIntersects::try_new_array(polygons.clone(), query.clone()), + &mut ctx, + ) + }); + } +} diff --git a/vortex-geo/benches/distance.rs b/vortex-geo/benches/distance.rs new file mode 100644 index 00000000000..3f68514b3a5 --- /dev/null +++ b/vortex-geo/benches/distance.rs @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Production baselines for planar `ST_Distance` over native geometry columns. +//! +//! Point cases use 16,384 rows. Polygon distance rebuilds both geometry R-trees per call, so its +//! smaller fixture still exercises that work without making CodSpeed's amd64/AVX2 CPU simulation +//! dominate the shard. + +#![expect(clippy::unwrap_used)] + +use std::sync::LazyLock; + +use divan::Bencher; +use divan::counter::ItemsCount; +use mimalloc::MiMalloc; +use vortex_array::ArrayRef; +use vortex_array::Canonical; +use vortex_array::ExecutionCtx; +use vortex_array::IntoArray; +use vortex_array::VortexSessionExecute; +use vortex_array::arrays::ConstantArray; +use vortex_array::arrays::MaskedArray; +use vortex_array::validity::Validity; +use vortex_error::VortexResult; +use vortex_geo::scalar_fn::distance::GeoDistance; +use vortex_geo::test_harness::geo_session; +use vortex_geo::test_harness::point_column; +use vortex_geo::test_harness::polygon_column; +use vortex_session::VortexSession; + +// Scalar function execution allocates its output inside the timed region, so use the vendored +// allocator instead of measuring glibc differences between CodSpeed runner images. +#[global_allocator] +static GLOBAL: MiMalloc = MiMalloc; + +static SESSION: LazyLock = LazyLock::new(geo_session); + +const POINT_ROWS: usize = 16_384; +const POLYGON_ROWS: usize = 1_024; + +fn main() { + divan::main(); +} + +/// Deterministic pseudo-random value in `[0, 1)`. +fn unit(i: usize) -> f64 { + ((i.wrapping_mul(2654435761) >> 8) % 10_000) as f64 / 10_000.0 +} + +fn points(rows: usize, offset: usize) -> ArrayRef { + let xs = (0..rows) + .map(|i| 300.0 * unit(i + offset) - 150.0) + .collect(); + let ys = (0..rows) + .map(|i| 300.0 * unit(i + offset + 1) - 150.0) + .collect(); + point_column(xs, ys).unwrap() +} + +/// A small square centered at `(cx, cy)`. +fn square(cx: f64, cy: f64) -> Vec> { + vec![vec![ + (cx - 1.0, cy - 1.0), + (cx + 1.0, cy - 1.0), + (cx + 1.0, cy + 1.0), + (cx - 1.0, cy + 1.0), + (cx - 1.0, cy - 1.0), + ]] +} + +fn polygons(rows: usize) -> ArrayRef { + let rows = (0..rows) + .map(|i| square(150.0 + 700.0 * unit(i), 150.0 + 700.0 * unit(i + 1))) + .collect(); + polygon_column(rows).unwrap() +} + +fn point_constant(x: f64, y: f64, rows: usize, ctx: &mut ExecutionCtx) -> ArrayRef { + let scalar = point_column(vec![x], vec![y]) + .unwrap() + .execute_scalar(0, ctx) + .unwrap(); + ConstantArray::new(scalar, rows).into_array() +} + +fn polygon_constant(rows: usize, ctx: &mut ExecutionCtx) -> ArrayRef { + let scalar = polygon_column(vec![square(0.0, 0.0)]) + .unwrap() + .execute_scalar(0, ctx) + .unwrap(); + ConstantArray::new(scalar, rows).into_array() +} + +fn execute(distance: VortexResult, ctx: &mut ExecutionCtx) -> ArrayRef { + distance + .unwrap() + .into_array() + .execute::(ctx) + .unwrap() + .into_array() +} + +fn bench_distance(bencher: Bencher, lhs: ArrayRef, rhs: ArrayRef) { + let rows = lhs.len(); + let mut ctx = SESSION.create_execution_ctx(); + bencher.counter(ItemsCount::new(rows)).bench_local(|| { + execute( + GeoDistance::try_new_array(lhs.clone(), rhs.clone()), + &mut ctx, + ) + }); +} + +#[divan::bench] +fn point_column_x_point_column(bencher: Bencher) { + bench_distance(bencher, points(POINT_ROWS, 0), points(POINT_ROWS, 97)); +} + +#[divan::bench] +fn point_column_x_constant_point(bencher: Bencher) { + let mut ctx = SESSION.create_execution_ctx(); + let point = point_constant(0.0, 0.0, POINT_ROWS, &mut ctx); + bench_distance(bencher, points(POINT_ROWS, 0), point); +} + +#[divan::bench] +fn polygon_column_x_constant_polygon(bencher: Bencher) { + let mut ctx = SESSION.create_execution_ctx(); + let polygon = polygon_constant(POLYGON_ROWS, &mut ctx); + bench_distance(bencher, polygons(POLYGON_ROWS), polygon); +} + +#[divan::bench] +fn nullable_point_column_x_constant_point(bencher: Bencher) { + let validity = Validity::from_iter((0..POINT_ROWS).map(|i| !i.is_multiple_of(8))); + let points = MaskedArray::try_new(points(POINT_ROWS, 0), validity) + .unwrap() + .into_array(); + let mut ctx = SESSION.create_execution_ctx(); + let point = point_constant(0.0, 0.0, POINT_ROWS, &mut ctx); + bench_distance(bencher, points, point); +} diff --git a/vortex-geo/benches/envelope.rs b/vortex-geo/benches/envelope.rs index 6ae5d6404c2..2115cf88c9c 100644 --- a/vortex-geo/benches/envelope.rs +++ b/vortex-geo/benches/envelope.rs @@ -20,6 +20,7 @@ use std::sync::LazyLock; use divan::Bencher; use divan::counter::ItemsCount; +use mimalloc::MiMalloc; use vortex_array::ArrayRef; use vortex_array::Canonical; use vortex_array::ExecutionCtx; @@ -35,6 +36,11 @@ use vortex_geo::test_harness::nullable_point_column; use vortex_geo::test_harness::point_column; use vortex_session::VortexSession; +// Scalar function execution allocates its output inside the timed region, so use the vendored +// allocator instead of measuring glibc differences between CodSpeed runner images. +#[global_allocator] +static GLOBAL: MiMalloc = MiMalloc; + fn main() { divan::main(); } diff --git a/vortex-tensor/Cargo.toml b/vortex-tensor/Cargo.toml index abe63fde595..abdca676775 100644 --- a/vortex-tensor/Cargo.toml +++ b/vortex-tensor/Cargo.toml @@ -32,7 +32,25 @@ num-traits = { workspace = true } prost = { workspace = true } [dev-dependencies] +divan = { workspace = true } +mimalloc = { workspace = true } rstest = { workspace = true } vortex-array = { workspace = true, features = ["_test-harness"] } vortex-btrblocks = { workspace = true } vortex-mask = { workspace = true } + +[[bench]] +name = "cosine_similarity" +harness = false + +[[bench]] +name = "l2_norm" +harness = false + +[[bench]] +name = "normalized" +harness = false + +[[bench]] +name = "inner_product" +harness = false diff --git a/vortex-tensor/benches/cosine_similarity.rs b/vortex-tensor/benches/cosine_similarity.rs new file mode 100644 index 00000000000..e44a3210da0 --- /dev/null +++ b/vortex-tensor/benches/cosine_similarity.rs @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Baseline throughput for cosine similarity over tensor columns. +//! +//! The arms cover pairwise columns and both representations of a broadcast query vector. These +//! names are intended to remain stable across scalar-function implementation changes so CodSpeed +//! can compare them against `develop`. + +#![expect(clippy::unwrap_used)] + +use divan::Bencher; +use mimalloc::MiMalloc; +use vortex_array::ArrayRef; +use vortex_array::EmptyMetadata; +use vortex_array::IntoArray; +use vortex_array::VortexSessionExecute; +use vortex_array::arrays::ConstantArray; +use vortex_array::arrays::ExtensionArray; +use vortex_array::arrays::FixedSizeListArray; +use vortex_array::arrays::PrimitiveArray; +use vortex_array::dtype::DType; +use vortex_array::dtype::Nullability; +use vortex_array::dtype::PType; +use vortex_array::scalar::Scalar; +use vortex_array::validity::Validity; +use vortex_buffer::Buffer; +use vortex_tensor::scalar_fns::cosine_similarity::CosineSimilarity; +use vortex_tensor::vector::Vector; + +// Scalar function execution allocates its output inside the timed region, so use the vendored +// allocator instead of measuring glibc differences between CodSpeed runner images. +#[global_allocator] +static GLOBAL: MiMalloc = MiMalloc; + +fn main() { + divan::main(); +} + +const ROWS: usize = 16_384; + +/// Widths chosen to separate the two costs, as in `l2_norm.rs`: the redundant norm pass is +/// `O(rows * width)`, one third of the closure's arithmetic, so wide tensors show the hoist +/// while a narrow one is dominated by per-row framework costs. +const WIDTHS: &[usize] = &[2, 32, 256]; + +/// [`ROWS`] vectors of `width` `f64` elements, non-nullable. `seed` offsets the values so the +/// two sides of the column arm are not the same array. +fn vectors(width: usize, seed: usize) -> ArrayRef { + let elements: Buffer = (0..ROWS * width) + .map(|i| (((i + seed) % 97) as f64) - 48.0) + .collect(); + let storage = FixedSizeListArray::new( + elements.into_array(), + u32::try_from(width).unwrap(), + Validity::NonNullable, + ROWS, + ) + .into_array(); + Vector::try_new_vector_array(storage).unwrap() +} + +/// One query vector of `width` `f64` elements broadcast to [`ROWS`] rows, as a +/// [`ConstantArray`] over a [`Vector`] extension scalar. +fn constant_vector(width: usize) -> ArrayRef { + let element_dtype = DType::Primitive(PType::F64, Nullability::NonNullable); + let children: Vec = (0..width) + .map(|i| Scalar::primitive(((i % 97) as f64) - 48.0, Nullability::NonNullable)) + .collect(); + let fsl_scalar = Scalar::fixed_size_list(element_dtype, children, Nullability::NonNullable); + let ext_scalar = Scalar::extension::(EmptyMetadata, fsl_scalar); + ConstantArray::new(ext_scalar, ROWS).into_array() +} + +fn bench_cosine(bencher: Bencher, lhs: ArrayRef, rhs: ArrayRef) { + let session = vortex_array::array_session(); + bencher + .with_inputs(|| { + ( + CosineSimilarity::try_new_array(lhs.clone(), rhs.clone()) + .unwrap() + .into_array(), + session.create_execution_ctx(), + ) + }) + .bench_values(|(array, mut ctx)| array.execute::(&mut ctx).unwrap()); +} + +/// The control: both operands vary by row, so every norm must be computed in the row loop. +#[divan::bench(args = WIDTHS)] +fn column_x_column(bencher: Bencher, width: usize) { + bench_cosine(bencher, vectors(width, 0), vectors(width, 31)); +} + +/// The rhs is a broadcast query vector, whose norm is the same in every row. +#[divan::bench(args = WIDTHS)] +fn column_x_constant(bencher: Bencher, width: usize) { + bench_cosine(bencher, vectors(width, 0), constant_vector(width)); +} + +/// One query vector represented as an extension array over constant storage. +fn extension_constant_vector(width: usize) -> ArrayRef { + let ext_dtype = vectors(width, 0).dtype().as_extension().clone(); + let element_dtype = DType::Primitive(PType::F64, Nullability::NonNullable); + let children: Vec = (0..width) + .map(|i| Scalar::primitive(((i % 97) as f64) - 48.0, Nullability::NonNullable)) + .collect(); + let fsl_scalar = Scalar::fixed_size_list(element_dtype, children, Nullability::NonNullable); + ExtensionArray::new(ext_dtype, ConstantArray::new(fsl_scalar, ROWS).into_array()).into_array() +} + +/// The rhs is the same broadcast query represented as extension-wrapped constant storage. +#[divan::bench(args = WIDTHS)] +fn column_x_extension_constant(bencher: Bencher, width: usize) { + bench_cosine(bencher, vectors(width, 0), extension_constant_vector(width)); +} diff --git a/vortex-tensor/benches/inner_product.rs b/vortex-tensor/benches/inner_product.rs new file mode 100644 index 00000000000..dbfb9e1612d --- /dev/null +++ b/vortex-tensor/benches/inner_product.rs @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Baseline throughput for `inner_product` over tensor columns. +//! +//! The arms vary vector width and input nullability. Their names are intended to remain stable +//! across scalar-function implementation changes so CodSpeed can compare them against `develop`. + +#![expect(clippy::unwrap_used)] + +use divan::Bencher; +use divan::counter::ItemsCount; +use mimalloc::MiMalloc; +use vortex_array::ArrayRef; +use vortex_array::IntoArray; +use vortex_array::VortexSessionExecute; +use vortex_array::arrays::FixedSizeListArray; +use vortex_array::arrays::MaskedArray; +use vortex_array::arrays::PrimitiveArray; +use vortex_array::validity::Validity; +use vortex_buffer::Buffer; +use vortex_tensor::scalar_fns::inner_product::InnerProduct; +use vortex_tensor::vector::Vector; + +// Scalar function execution allocates its output inside the timed region, so use the vendored +// allocator instead of measuring glibc differences between CodSpeed runner images. +#[global_allocator] +static GLOBAL: MiMalloc = MiMalloc; + +fn main() { + divan::main(); +} + +const ROWS: usize = 16_384; +const WIDTHS: &[usize] = &[2, 32, 256]; + +fn vectors(width: usize, seed: usize) -> ArrayRef { + let elements: Buffer = (0..ROWS * width) + .map(|i| (((i + seed) % 97) as f64) - 48.0) + .collect(); + let storage = FixedSizeListArray::new( + elements.into_array(), + u32::try_from(width).unwrap(), + Validity::NonNullable, + ROWS, + ) + .into_array(); + Vector::try_new_vector_array(storage).unwrap() +} + +fn bench_inner_product(bencher: Bencher, lhs: ArrayRef, rhs: ArrayRef) { + let session = vortex_array::array_session(); + bencher + .counter(ItemsCount::new(ROWS)) + .with_inputs(|| { + ( + InnerProduct::try_new_array(lhs.clone(), rhs.clone()) + .unwrap() + .into_array(), + session.create_execution_ctx(), + ) + }) + .bench_values(|(array, mut ctx)| array.execute::(&mut ctx).unwrap()); +} + +#[divan::bench(args = WIDTHS)] +fn non_nullable(bencher: Bencher, width: usize) { + bench_inner_product(bencher, vectors(width, 0), vectors(width, 31)); +} + +#[divan::bench(args = WIDTHS)] +fn nullable(bencher: Bencher, width: usize) { + let validity = Validity::from_iter((0..ROWS).map(|i| i % 8 != 0)); + let lhs = MaskedArray::try_new(vectors(width, 0), validity) + .unwrap() + .into_array(); + bench_inner_product(bencher, lhs, vectors(width, 31)); +} diff --git a/vortex-tensor/benches/l2_norm.rs b/vortex-tensor/benches/l2_norm.rs new file mode 100644 index 00000000000..bcc71fe8a34 --- /dev/null +++ b/vortex-tensor/benches/l2_norm.rs @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Baseline throughput for `l2_norm` over tensor columns. +//! +//! The arms vary vector width and input nullability. Their names are intended to remain stable +//! across scalar-function implementation changes so CodSpeed can compare them against `develop`. + +#![expect(clippy::unwrap_used)] + +use divan::Bencher; +use divan::counter::ItemsCount; +use mimalloc::MiMalloc; +use vortex_array::ArrayRef; +use vortex_array::IntoArray; +use vortex_array::VortexSessionExecute; +use vortex_array::arrays::FixedSizeListArray; +use vortex_array::arrays::MaskedArray; +use vortex_array::arrays::PrimitiveArray; +use vortex_array::validity::Validity; +use vortex_buffer::Buffer; +use vortex_tensor::scalar_fns::l2_norm::L2Norm; +use vortex_tensor::vector::Vector; + +// Scalar function execution allocates its output inside the timed region, so use the vendored +// allocator instead of measuring glibc differences between CodSpeed runner images. +#[global_allocator] +static GLOBAL: MiMalloc = MiMalloc; + +fn main() { + divan::main(); +} + +const ROWS: usize = 16_384; +const WIDTHS: &[usize] = &[2, 32, 256]; + +fn vectors(width: usize) -> ArrayRef { + let elements: Buffer = (0..ROWS * width) + .map(|i| ((i % 97) as f64) - 48.0) + .collect(); + let storage = FixedSizeListArray::new( + elements.into_array(), + u32::try_from(width).unwrap(), + Validity::NonNullable, + ROWS, + ) + .into_array(); + Vector::try_new_vector_array(storage).unwrap() +} + +fn bench_l2_norm(bencher: Bencher, input: ArrayRef) { + let session = vortex_array::array_session(); + bencher + .counter(ItemsCount::new(ROWS)) + .with_inputs(|| { + ( + L2Norm::try_new_array(input.clone()).unwrap().into_array(), + session.create_execution_ctx(), + ) + }) + .bench_values(|(array, mut ctx)| array.execute::(&mut ctx).unwrap()); +} + +#[divan::bench(args = WIDTHS)] +fn non_nullable(bencher: Bencher, width: usize) { + bench_l2_norm(bencher, vectors(width)); +} + +#[divan::bench(args = WIDTHS)] +fn nullable(bencher: Bencher, width: usize) { + let validity = Validity::from_iter((0..ROWS).map(|i| i % 8 != 0)); + let input = MaskedArray::try_new(vectors(width), validity) + .unwrap() + .into_array(); + bench_l2_norm(bencher, input); +} diff --git a/vortex-tensor/benches/normalized.rs b/vortex-tensor/benches/normalized.rs new file mode 100644 index 00000000000..fd164f82d15 --- /dev/null +++ b/vortex-tensor/benches/normalized.rs @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! Baseline throughput for decoding the `Normalized` encoding over tensor columns. +//! +//! The arms vary vector width and input nullability. Their names are intended to remain stable +//! across implementation changes so CodSpeed can compare them against `develop`. + +#![expect(clippy::unwrap_used)] + +use divan::Bencher; +use divan::counter::ItemsCount; +use mimalloc::MiMalloc; +use vortex_array::ArrayRef; +use vortex_array::IntoArray; +use vortex_array::VortexSessionExecute; +use vortex_array::arrays::ExtensionArray; +use vortex_array::arrays::FixedSizeListArray; +use vortex_array::arrays::MaskedArray; +use vortex_array::arrays::PrimitiveArray; +use vortex_array::validity::Validity; +use vortex_buffer::Buffer; +use vortex_tensor::encodings::normalized::Normalized; +use vortex_tensor::vector::Vector; + +// Decoding allocates the output inside the timed region, so use the vendored allocator instead +// of measuring glibc differences between CodSpeed runner images. +#[global_allocator] +static GLOBAL: MiMalloc = MiMalloc; + +fn main() { + divan::main(); +} + +const ROWS: usize = 16_384; +const WIDTHS: &[usize] = &[2, 32, 256]; + +fn normalized_vectors(width: usize) -> ArrayRef { + let value = 1.0 / (width as f64).sqrt(); + let elements: Buffer = (0..ROWS * width).map(|_| value).collect(); + let storage = FixedSizeListArray::new( + elements.into_array(), + u32::try_from(width).unwrap(), + Validity::NonNullable, + ROWS, + ) + .into_array(); + Vector::try_new_vector_array(storage).unwrap() +} + +fn norms() -> ArrayRef { + let values: Buffer = (0..ROWS).map(|i| 1.0 + ((i % 13) as f64) / 13.0).collect(); + PrimitiveArray::new(values, Validity::NonNullable).into_array() +} + +fn bench_normalized(bencher: Bencher, normalized: ArrayRef) { + let session = vortex_array::array_session(); + let norms = norms(); + bencher + .counter(ItemsCount::new(ROWS)) + .with_inputs(|| { + let mut ctx = session.create_execution_ctx(); + let array = Normalized::try_new(normalized.clone(), norms.clone(), &mut ctx).unwrap(); + (array, ctx) + }) + .bench_values(|(array, mut ctx)| { + array + .into_array() + .execute::(&mut ctx) + .unwrap() + }); +} + +#[divan::bench(args = WIDTHS)] +fn non_nullable(bencher: Bencher, width: usize) { + bench_normalized(bencher, normalized_vectors(width)); +} + +#[divan::bench(args = WIDTHS)] +fn nullable(bencher: Bencher, width: usize) { + let validity = Validity::from_iter((0..ROWS).map(|i| i % 8 != 0)); + let normalized = MaskedArray::try_new(normalized_vectors(width), validity) + .unwrap() + .into_array(); + bench_normalized(bencher, normalized); +}