From 93add4afce637576cacc6198ce43df7c6e4372c4 Mon Sep 17 00:00:00 2001 From: Connor Tsui Date: Wed, 22 Jul 2026 13:46:11 -0400 Subject: [PATCH 01/13] Read the data child directly when the zone map is empty When a zoned layout has no usable zone map (`zone_len == 0`, either a legacy zero-length zone or a layout whose aggregates this session cannot reconstruct), `new_reader` now returns the data child's reader directly instead of building a passthrough `ZonedReader`. This unifies the legacy zero-zone path with the unknown-aggregate fallback and lets us delete the now-unreachable `zone_len == 0` branch inside `ZonedReader::pruning_evaluation`. Signed-off-by: Connor Tsui Co-Authored-By: Claude Opus 4.8 --- vortex-layout/src/layouts/zoned/mod.rs | 45 ++++++++++++++++------- vortex-layout/src/layouts/zoned/reader.rs | 24 ++++++++---- 2 files changed, 48 insertions(+), 21 deletions(-) diff --git a/vortex-layout/src/layouts/zoned/mod.rs b/vortex-layout/src/layouts/zoned/mod.rs index d4e5d9350be..3a4d80902f3 100644 --- a/vortex-layout/src/layouts/zoned/mod.rs +++ b/vortex-layout/src/layouts/zoned/mod.rs @@ -65,6 +65,35 @@ use crate::vtable; vtable!(Zoned); vtable!(LegacyStats); +/// Builds a reader for a zoned layout, bypassing [`ZonedReader`] when the zone map is empty +/// (`zone_len == 0`) and reading the data child directly, since there is nothing to prune with. +/// This covers both legacy zero-length zones and layouts whose aggregates the session cannot +/// reconstruct. +fn zoned_reader( + layout: &ZonedLayout, + name: Arc, + segment_source: Arc, + session: &VortexSession, + ctx: &crate::LayoutReaderContext, +) -> VortexResult { + if layout.zone_len == 0 { + return layout.children.child(0, &layout.dtype)?.new_reader( + name, + segment_source, + session, + ctx, + ); + } + + Ok(Arc::new(ZonedReader::try_new( + layout.clone(), + name, + segment_source, + session.clone(), + ctx.clone(), + )?)) +} + impl VTable for Zoned { type Layout = ZonedLayout; type Encoding = ZonedLayoutEncoding; @@ -134,13 +163,7 @@ impl VTable for Zoned { session: &VortexSession, ctx: &crate::LayoutReaderContext, ) -> VortexResult { - Ok(Arc::new(ZonedReader::try_new( - layout.clone(), - name, - segment_source, - session.clone(), - ctx.clone(), - )?)) + zoned_reader(layout, name, segment_source, session, ctx) } fn build( @@ -251,13 +274,7 @@ impl VTable for LegacyStats { session: &VortexSession, ctx: &crate::LayoutReaderContext, ) -> VortexResult { - Ok(Arc::new(ZonedReader::try_new( - layout.0.clone(), - name, - segment_source, - session.clone(), - ctx.clone(), - )?)) + zoned_reader(&layout.0, name, segment_source, session, ctx) } fn build( diff --git a/vortex-layout/src/layouts/zoned/reader.rs b/vortex-layout/src/layouts/zoned/reader.rs index 162742b6f22..a6d00c84d84 100644 --- a/vortex-layout/src/layouts/zoned/reader.rs +++ b/vortex-layout/src/layouts/zoned/reader.rs @@ -72,8 +72,8 @@ impl ZonedReader { /// Get the range of zone IDs containing a row range. pub(crate) fn zone_range(&self, row_range: &Range) -> Range { - // Caller must ensure zone_len > 0. Legacy files may deserialize with zone_len == 0, but - // pruning_evaluation disables zoned pruning for those layouts before calling this helper. + // Callers rely on `zone_len > 0`. `new_reader` never constructs a `ZonedReader` for a + // zero-length zone map (it reads the data child directly), so this holds by construction. debug_assert!(self.layout.zone_len > 0, "zone_len must be > 0"); let zone_len_u64 = self.layout.zone_len as u64; @@ -128,11 +128,6 @@ impl LayoutReader for ZonedReader { .data_child()? .pruning_evaluation(row_range, expr, mask.clone())?; - if self.layout.zone_len == 0 { - trace!("Stats pruning evaluation: skipping zoned pruning for legacy zero-length zones"); - return Ok(data_eval); - } - let Some(pruning_mask_future) = self.pruning.pruning_mask_future(expr.clone()) else { trace!("Stats pruning evaluation: not prune-able {expr}"); return Ok(data_eval); @@ -469,6 +464,21 @@ mod test { .await?; assert!(result.all_true()); + + // The bypass returns the data child's reader, so projection reads the underlying data. + let projected = reader + .projection_evaluation( + &(0..row_count), + &root(), + MaskFuture::new_true(row_count.try_into().unwrap()), + )? + .await?; + let mut ctx = array_session().create_execution_ctx(); + assert_arrays_eq!( + projected, + buffer![1i32, 2, 3, 4, 5, 6, 7, 8, 9].into_array(), + &mut ctx + ); Ok(()) }) } From fe44b2f299b445687b4b632411ca276ce3d3dac5 Mon Sep 17 00:00:00 2001 From: Connor Tsui Date: Wed, 22 Jul 2026 15:06:50 -0400 Subject: [PATCH 02/13] Prototype Bloom and trigram skipping indexes Signed-off-by: Connor Tsui --- .github/workflows/sql-benchmarks.yml | 17 +- SKIPPING_INDEX_SPIKE.md | 118 ++++ benchmarks/datafusion-bench/src/main.rs | 5 +- scripts/capture-file-sizes.py | 13 +- vortex-bench/src/conversions.rs | 59 +- vortex-bench/src/lib.rs | 71 ++ vortex-file/src/strategy.rs | 72 +- vortex-file/tests/experimental_bloom.rs | 632 ++++++++++++++++++ .../src/layouts/zoned/experimental.rs | 593 ++++++++++++++++ .../src/layouts/zoned/experimental/ngram.rs | 521 +++++++++++++++ vortex-layout/src/layouts/zoned/mod.rs | 1 + vortex-layout/src/layouts/zoned/writer.rs | 6 +- 12 files changed, 2069 insertions(+), 39 deletions(-) create mode 100644 SKIPPING_INDEX_SPIKE.md create mode 100644 vortex-file/tests/experimental_bloom.rs create mode 100644 vortex-layout/src/layouts/zoned/experimental.rs create mode 100644 vortex-layout/src/layouts/zoned/experimental/ngram.rs diff --git a/.github/workflows/sql-benchmarks.yml b/.github/workflows/sql-benchmarks.yml index 7c4b69a2539..2356b87a5c0 100644 --- a/.github/workflows/sql-benchmarks.yml +++ b/.github/workflows/sql-benchmarks.yml @@ -531,6 +531,12 @@ jobs: timeout-minutes: 120 env: VORTEX_EXPERIMENTAL_PATCHED_ARRAY: "1" + VORTEX_EXPERIMENTAL_SKIP_INDEX: "1" + VORTEX_EXPERIMENTAL_SKIP_INDEX_DIAGNOSTICS: "1" + VORTEX_EXPERIMENTAL_SKIP_INDEX_DIR: "vortex-file-skipping" + VORTEX_EXPERIMENTAL_NGRAM_BYTES: "16384" + VORTEX_EXPERIMENTAL_NGRAM_HASHES: "5" + VORTEX_EXPERIMENTAL_NGRAM_ZONE_LEN: ${{ startsWith(matrix.id, 'fineweb') && '1024' || '8192' }} FLAT_LAYOUT_INLINE_ARRAY_NODE: "1" # Makes python output nicer COLUMNS: 120 @@ -538,6 +544,15 @@ jobs: fail-fast: false matrix: include: ${{ fromJSON(inputs.benchmark_profile == 'base' && inputs.base_benchmark_matrix || inputs.benchmark_matrix) }} + exclude: + - id: appian-nvme + - id: polarsignals + - id: statpopgen + - id: tpcds-nvme + - id: tpch-nvme + - id: tpch-nvme-10 + - id: tpch-s3 + - id: tpch-s3-10 runs-on: >- ${{ github.repository == 'vortex-data/vortex' @@ -579,7 +594,7 @@ jobs: shell: bash run: | if [ "${{ inputs.mode }}" = "pr" ]; then - targets='${{ toJSON(matrix.pr_targets) }}' + targets='[{"engine":"datafusion","format":"vortex"}]' else targets='${{ toJSON(matrix.develop_targets) }}' fi diff --git a/SKIPPING_INDEX_SPIKE.md b/SKIPPING_INDEX_SPIKE.md new file mode 100644 index 00000000000..7dac4e6f635 --- /dev/null +++ b/SKIPPING_INDEX_SPIKE.md @@ -0,0 +1,118 @@ +# Experimental skipping-index findings + +This spike answers the core questions in [#8901](https://github.com/vortex-data/vortex/issues/8901): a third-party-style aggregate can be persisted in a `ZonedLayout`, rebound on a fresh reader, and used by an equality rewrite to skip zones without changing scan planning. + +## Result + +Yes, it works. The prototype adds experimental `i64` equality and UTF-8 trigram-bloom indexes consisting of: + +- a serializable per-zone `BloomFilter` aggregate stored as `Binary`, +- a `bloom_contains` scalar probe, +- an equality `StatsRewriteRule` that emits `not(bloom_contains(stat(root(), bloom), literal))`, and +- a small `SkipIndex` bundle plus `ZonedLayoutOptions::with_skip_index` as the explicit write-side declaration. + +The trigram variant stores every three-byte window from a zone and rewrites case-sensitive +`LIKE` expressions when their pattern contains at least one three-byte literal run. For +`LIKE '%needle%'`, a zone is skipped if any required trigram is definitely absent. SQL `%` and +`_` wildcards and backslash escapes are parsed conservatively; `ILIKE`, negated `LIKE`, and patterns +without a three-byte literal remain inconclusive. + +The roundtrip tests write four zones, open the files with fresh registered sessions, and check both +hits and misses. The equality hit keeps exactly one data zone. Its absent value is deliberately +inside every zone's min/max range, but the bloom proof skips all four zones. The substring hit also +keeps exactly one zone and an absent substring skips all four. Results from every indexed scan are +array-equal to scans through fresh sessions without the custom indexes. + +The unregistered reader case also works when `allow_unknown()` is enabled: it ignores the unknown aggregate and reads the data without pruning. That behavior depends on #8904 and #8905. As of 2026-07-22 both PRs are open and absent from `develop`, so their commits are included on this experimental branch as prerequisites. + +## Benchmark + +Command: + +```text +cargo test --release -p vortex-file --test experimental_bloom bloom_point_lookup_benchmark -- --ignored --nocapture +``` + +The focused benchmark uses 2,097,152 interleaved, high-cardinality `i64` values in 256 zones of 8,192 rows. Interleaving makes the point lookup fall inside every zone's min/max range, so ordinary range statistics cannot skip a zone. Timings are medians of nine fresh-reader scans after two warmups. Segment payload counts are measured below the file reader with a counting `SegmentSource`. + +| Case | Zones pruned | File size | Median | Segment requests | Unique segments | Segment payload | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| No bloom | 0/256 | 16,845,924 B | 1.541 ms | 258 | 257 | 16,897,520 B | +| Bloom | 251/256 | 18,947,380 B | 0.571 ms | 7 | 6 | 2,504,196 B | + +The bloom scan was **2.70x faster** and moved **6.75x less segment payload**. A preceding clean run measured 2.57x, giving a 2.57-2.70x repeat range. Five zones survived: the one true hit and four bloom false positives. The file grew by 2,101,456 bytes, or 12.5%, from one 8 KiB bloom per zone plus layout overhead. + +The negative control uses cardinality 16 and queries a value present in every zone: + +| Case | Zones pruned | File size | Median | Segment requests | Unique segments | Segment payload | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| No bloom | 0/256 | 16,843,876 B | 1.807 ms | 513 | 257 | 33,651,004 B | +| Bloom | 0/256 | 18,945,332 B | 1.832 ms | 513 | 257 | 35,752,396 B | + +With nothing to prune, the indexed scan was 1.4% slower (0.986x) and caused 6.2% more segment payload. A bloom only makes sense when avoided data reads pay for loading and probing the zone table; it does not help low-cardinality or non-selective equality predicates. + +These are in-memory buffer results from a focused release test, not storage-system throughput claims. Remote or high-latency storage should amplify the request reduction, while a very fast cached scan or smaller zones may make zone-map overhead more visible. + +### Existing SQL suites + +The benchmark branch also wires the indexes into the normal compressed writer without replacing +its dictionary, compressor, coalescing, or flat-layout stages: + +- ClickBench `UserID`: equality bloom, 8,192-row zones. +- ClickBench `URL` and `Title`: 16 KiB trigram blooms, 8,192-row zones. +- FineWeb `url`, `text`, and `file_path`: 16 KiB trigram blooms, 1,024-row zones. + +The benchmark artifact is written to a separate `vortex-file-skipping` directory so cached normal +Vortex files cannot contaminate the comparison. The focused draft-PR run covers ClickBench, +ClickBench sorted, FineWeb NVMe, and FineWeb S3 with DataFusion/Vortex. Results will be recorded here +after the dedicated benchmark run completes. + +### Off-the-shelf pieces + +Arrow Rust's `parquet::bloom_filter::Sbbf` is a credible production filter primitive: it has stable +split-block Bloom semantics, byte serialization, membership checks, NDV/FPP sizing, and folding. +It does not replace the Vortex-specific aggregate persistence, stats rewrite, probe expression, or +per-column writer declaration, and using it directly from `vortex-layout` would add an undesirable +Parquet dependency to the layout crate. The prototype therefore keeps a small mergeable bitset for +the experiment. For production, put an SBBF-compatible filter in a small neutral crate (or extract +the Arrow implementation) and reuse it for both equality and n-gram indexes. Pulling in a search +engine or tokenizer such as Tantivy only to enumerate byte trigrams would be substantially heavier +than the operation itself. + +## Interface recommendation + +Keep the aggregate, scalar function, and rewrite registries composable, but add a single bundle at the user-facing registration and write-declaration boundary: + +```rust +pub trait SkipIndex: Debug + Send + Sync + 'static { + fn aggregate_fn(&self, input_dtype: &DType) -> Option; + fn register(&self, session: &VortexSession); +} + +impl ZonedLayoutOptions { + pub fn with_skip_index( + self, + index: &I, + input_dtype: &DType, + session: &VortexSession, + ) -> VortexResult; +} +``` + +This is preferable to replacing the existing extension points with one large vtable. An index +author can still reuse built-in aggregates or probes, while callers have one object to register on +writer and reader sessions and one explicit per-column declaration. The prototype adds +`WriteStrategyBuilder::with_field_zoned_options` as the narrow per-column seam, retaining the normal +data pipeline instead of requiring callers to reconstruct it. + +## Awkward parts and next steps + +- Aggregate identity includes serialized options. The reader must register a rewrite using exactly the same bloom sizing and hash count as the writer; a mismatched index safely becomes inconclusive, but discovery should be automatic in a production API. +- Choosing filter size, hash count, gram size, and zone length is workload-specific. In particular, + trigrams for large text zones can saturate, while very small zones impose substantial file-size + and metadata overhead. +- Unknown aggregate fallback currently disables that zone map rather than using the known statistics alongside it. That is safe but leaves pruning performance on the table. +- The hash algorithm, format version, supported dtypes, null semantics, sizing policy, and saturation behavior need an explicit compatibility contract. This spike supports `i64` equality and byte-oriented UTF-8 trigrams only. +- Bloom stats for all zones are regular child-array data. Their layout and caching should be profiled for many indexed columns and remote reads. + +Recommendation: pursue the bundle interface and an explicit per-column writer declaration, while retaining the three lower-level registries. The selective equality benchmark is large enough to justify a production-quality experiment, but index selection must remain workload-aware and opt-in. The SQL run must show that real substring selectivity survives filter saturation and pays back the added file bytes before the trigram prototype is considered a win. diff --git a/benchmarks/datafusion-bench/src/main.rs b/benchmarks/datafusion-bench/src/main.rs index 67521a8147e..f37b59cb4af 100644 --- a/benchmarks/datafusion-bench/src/main.rs +++ b/benchmarks/datafusion-bench/src/main.rs @@ -42,6 +42,7 @@ use vortex_bench::conversions::convert_parquet_directory_to_vortex; use vortex_bench::create_benchmark; use vortex_bench::create_output_writer; use vortex_bench::display::DisplayFormat; +use vortex_bench::format_data_dir; use vortex_bench::runner::BenchmarkMode; use vortex_bench::runner::BenchmarkQueryResult; use vortex_bench::runner::SqlBenchmarkRunner; @@ -262,7 +263,9 @@ async fn register_benchmark_tables( register_v2_tables(session, benchmark, format).await } _ => { - let benchmark_base = benchmark.data_url().join(&format!("{}/", format.name()))?; + let benchmark_base = benchmark + .data_url() + .join(&format!("{}/", format_data_dir(format)))?; let file_format = format_to_df_format(format); for table in benchmark.table_specs().iter() { diff --git a/scripts/capture-file-sizes.py b/scripts/capture-file-sizes.py index d923813db66..f5aa84b2558 100644 --- a/scripts/capture-file-sizes.py +++ b/scripts/capture-file-sizes.py @@ -11,6 +11,7 @@ import argparse import json +import os import sys from pathlib import Path @@ -42,7 +43,10 @@ def main(): # Formats to capture (vortex formats only, not parquet/duckdb) # Note: "vortex" CLI arg maps to "vortex-file-compressed" directory name + experimental_vortex_dir = os.environ.get("VORTEX_EXPERIMENTAL_SKIP_INDEX_DIR") formats_to_capture = {"vortex-file-compressed", "vortex-compact"} + if experimental_vortex_dir: + formats_to_capture.add(experimental_vortex_dir) records = [] @@ -54,9 +58,14 @@ def main(): if not format_dir.is_dir(): continue - format_name = format_dir.name - if format_name not in formats_to_capture: + directory_name = format_dir.name + if directory_name not in formats_to_capture: continue + format_name = ( + "vortex-file-compressed" + if directory_name == experimental_vortex_dir + else directory_name + ) # Extract scale factor from path (e.g., "1.0" for tpch/1.0/vortex-file-compressed) # Default to "1.0" if no intermediate directory (e.g., clickbench) diff --git a/vortex-bench/src/conversions.rs b/vortex-bench/src/conversions.rs index 92064c25785..496e702fe9b 100644 --- a/vortex-bench/src/conversions.rs +++ b/vortex-bench/src/conversions.rs @@ -38,6 +38,7 @@ use vortex::array::stream::ArrayStreamExt; use vortex::compressor::BtrBlocksCompressorBuilder; use vortex::dtype::DType; use vortex::dtype::FieldPath; +use vortex::dtype::PType; use vortex::dtype::StructFields; use vortex::dtype::extension::ExtDType; use vortex::dtype::extension::ExtDTypeRef; @@ -50,6 +51,8 @@ use vortex::layout::LayoutStrategy; use vortex::layout::layouts::chunked::writer::ChunkedLayoutStrategy; use vortex::layout::layouts::compressed::CompressingStrategy; use vortex::layout::layouts::flat::writer::FlatLayoutStrategy; +use vortex::layout::layouts::zoned::experimental::SkipIndex; +use vortex::layout::layouts::zoned::writer::ZonedLayoutOptions; use vortex::session::VortexSession; use vortex::utils::aliases::hash_set::HashSet; use vortex_arrow::FromArrowArray; @@ -64,6 +67,11 @@ use wkb::writer::write_geometry; use crate::CompactionStrategy; use crate::Format; use crate::SESSION; +use crate::experimental_bloom_index; +use crate::experimental_ngram_index; +use crate::experimental_ngram_zone_len; +use crate::experimental_skip_indexes_enabled; +use crate::format_data_dir; use crate::utils::file::idempotent_async; /// Memory budget per concurrent conversion stream in GB. This is somewhat arbitary. @@ -157,7 +165,7 @@ pub async fn convert_parquet_file_to_vortex( .open(output_path) .await?; - write_options_for(compaction, &dtype, is_spatialbench(parquet_path)) + write_options_for(compaction, &dtype, is_spatialbench(parquet_path))? .write( &mut output_file, ArrayStreamExt::boxed(ArrayStreamAdapter::new(dtype, stream)), @@ -181,7 +189,7 @@ fn write_options_for( compaction: CompactionStrategy, dtype: &DType, skip_binary_dict: bool, -) -> VortexWriteOptions { +) -> anyhow::Result { let binary_fields: Vec<_> = match dtype { DType::Struct(fields, _) if skip_binary_dict => fields .names() @@ -192,8 +200,11 @@ fn write_options_for( .collect(), _ => Vec::new(), }; - if binary_fields.is_empty() { - return compaction.apply_options(SESSION.write_options()); + if binary_fields.is_empty() + && (!experimental_skip_indexes_enabled() + || matches!(compaction, CompactionStrategy::Compact)) + { + return Ok(compaction.apply_options(SESSION.write_options())); } let mut builder = WriteStrategyBuilder::default(); @@ -204,7 +215,34 @@ fn write_options_for( for name in binary_fields { builder = builder.with_field_writer(FieldPath::from_name(name), no_dict_layout()); } - SESSION.write_options().with_strategy(builder.build()) + + if experimental_skip_indexes_enabled() + && matches!(compaction, CompactionStrategy::Default) + && let DType::Struct(fields, _) = dtype + { + let exact = experimental_bloom_index(); + let ngram = experimental_ngram_index(); + for (name, field_dtype) in fields.names().iter().zip(fields.fields()) { + let (index, block_size): (&dyn SkipIndex, _) = match (name.as_ref(), &field_dtype) { + ("UserID", DType::Primitive(PType::I64, _)) => ( + &exact, + std::num::NonZeroUsize::new(8192).unwrap_or(std::num::NonZeroUsize::MIN), + ), + ("URL" | "Title" | "url" | "text" | "file_path", DType::Utf8(_)) => { + (&ngram, experimental_ngram_zone_len()) + } + _ => continue, + }; + let options = ZonedLayoutOptions { + block_size, + ..Default::default() + } + .with_skip_index(index, &field_dtype, &SESSION)?; + builder = builder.with_field_zoned_options(FieldPath::from_name(name.clone()), options); + } + } + + Ok(SESSION.write_options().with_strategy(builder.build())) } /// A chunked + compressed layout that skips dictionary encoding for opaque `Binary` blobs. @@ -227,11 +265,16 @@ pub async fn convert_parquet_directory_to_vortex( compaction: CompactionStrategy, ) -> anyhow::Result<()> { let (format, dir_name) = match compaction { - CompactionStrategy::Compact => (Format::VortexCompact, Format::VortexCompact.name()), - CompactionStrategy::Default => (Format::OnDiskVortex, Format::OnDiskVortex.name()), + CompactionStrategy::Compact => ( + Format::VortexCompact, + Format::VortexCompact.name().to_string(), + ), + CompactionStrategy::Default => { + (Format::OnDiskVortex, format_data_dir(Format::OnDiskVortex)) + } }; - let vortex_dir = input_path.join(dir_name); + let vortex_dir = input_path.join(&dir_name); let parquet_path = input_path.join(Format::Parquet.name()); create_dir_all(&vortex_dir).await?; diff --git a/vortex-bench/src/lib.rs b/vortex-bench/src/lib.rs index abf4e1ccea3..3148c84d481 100644 --- a/vortex-bench/src/lib.rs +++ b/vortex-bench/src/lib.rs @@ -6,6 +6,8 @@ use std::clone::Clone; use std::fmt::Display; +use std::num::NonZeroU8; +use std::num::NonZeroUsize; use std::str::FromStr; use std::sync::LazyLock; @@ -72,6 +74,11 @@ pub use output::create_output_writer; use vortex::VortexSessionDefault; pub use vortex::error::vortex_panic; use vortex::io::session::RuntimeSessionExt; +use vortex::layout::layouts::zoned::experimental::BloomOptions; +use vortex::layout::layouts::zoned::experimental::BloomSkipIndex; +use vortex::layout::layouts::zoned::experimental::NGramBloomOptions; +use vortex::layout::layouts::zoned::experimental::NGramBloomSkipIndex; +use vortex::layout::layouts::zoned::experimental::SkipIndex; use vortex::session::VortexSession; // All benchmarks run with mimalloc for consistency. @@ -81,9 +88,73 @@ static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; pub static SESSION: LazyLock = LazyLock::new(|| { let session = VortexSession::default().with_tokio(); vortex_geo::initialize(&session); + if experimental_skip_indexes_enabled() { + experimental_bloom_index().register(&session); + experimental_ngram_index().register(&session); + } session }); +/// Whether benchmark files should be written and read with the experimental skipping indexes. +pub fn experimental_skip_indexes_enabled() -> bool { + std::env::var("VORTEX_EXPERIMENTAL_SKIP_INDEX").is_ok_and(|value| value == "1") +} + +/// Exact-value bloom index used by the benchmark experiment. +pub fn experimental_bloom_index() -> BloomSkipIndex { + BloomSkipIndex::default() +} + +/// N-gram bloom index used by the benchmark experiment, with environment-tunable sizing. +pub fn experimental_ngram_index() -> NGramBloomSkipIndex { + let defaults = NGramBloomOptions::default(); + let bytes = + experimental_nonzero_usize("VORTEX_EXPERIMENTAL_NGRAM_BYTES", defaults.bloom().bytes()); + let hashes = experimental_nonzero_u8( + "VORTEX_EXPERIMENTAL_NGRAM_HASHES", + defaults.bloom().hashes(), + ); + let gram_size = experimental_nonzero_u8("VORTEX_EXPERIMENTAL_NGRAM_SIZE", defaults.gram_size()); + NGramBloomSkipIndex::new(NGramBloomOptions::new( + BloomOptions::new(bytes, hashes), + gram_size, + )) +} + +/// Zone length used by string n-gram indexes in the benchmark experiment. +pub fn experimental_ngram_zone_len() -> NonZeroUsize { + experimental_nonzero_usize( + "VORTEX_EXPERIMENTAL_NGRAM_ZONE_LEN", + NonZeroUsize::new(1024).unwrap_or(NonZeroUsize::MIN), + ) +} + +fn experimental_nonzero_usize(name: &str, default: NonZeroUsize) -> NonZeroUsize { + std::env::var(name) + .ok() + .and_then(|value| value.parse().ok()) + .and_then(NonZeroUsize::new) + .unwrap_or(default) +} + +fn experimental_nonzero_u8(name: &str, default: NonZeroU8) -> NonZeroU8 { + std::env::var(name) + .ok() + .and_then(|value| value.parse().ok()) + .and_then(NonZeroU8::new) + .unwrap_or(default) +} + +/// Physical data directory for a benchmark format, with an optional side-by-side local override. +pub fn format_data_dir(format: Format) -> String { + if format == Format::OnDiskVortex && experimental_skip_indexes_enabled() { + std::env::var("VORTEX_EXPERIMENTAL_SKIP_INDEX_DIR") + .unwrap_or_else(|_| format.name().to_string()) + } else { + format.name().to_string() + } +} + #[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, Serialize, Deserialize)] pub struct Target { pub engine: Engine, diff --git a/vortex-file/src/strategy.rs b/vortex-file/src/strategy.rs index 763db758cb7..1e9dd6eafb4 100644 --- a/vortex-file/src/strategy.rs +++ b/vortex-file/src/strategy.rs @@ -157,6 +157,7 @@ pub struct WriteStrategyBuilder { compressor: CompressorConfig, row_block_size: usize, field_writers: HashMap>, + field_zoned_options: HashMap, allow_encodings: Option>, flat_strategy: Option>, probe_compressor: Option>, @@ -174,6 +175,7 @@ impl Default for WriteStrategyBuilder { compressor: CompressorConfig::BtrBlocks(BtrBlocksCompressorBuilder::default()), row_block_size: 8192, field_writers: HashMap::new(), + field_zoned_options: HashMap::new(), allow_encodings: Some(ALLOWED_ENCODINGS.clone()), flat_strategy: None, probe_compressor: None, @@ -216,6 +218,20 @@ impl WriteStrategyBuilder { self } + /// Override only the zoned-statistics options for a field while retaining the default + /// repartitioning, dictionary, compression, buffering, and flat-layout pipeline. + /// + /// This is an experimental seam for attaching custom per-zone aggregates without changing the + /// physical data strategy used for controlled comparisons. + pub fn with_field_zoned_options( + mut self, + field: impl Into, + options: ZonedLayoutOptions, + ) -> Self { + self.field_zoned_options.insert(field.into(), options); + self + } + /// Override the allowed array encodings for normalization. /// /// The configured flat leaf strategy is wrapped in a [`LayoutStrategyEncodingValidator`] @@ -260,7 +276,7 @@ impl WriteStrategyBuilder { /// Builds the canonical [`LayoutStrategy`] implementation, with the configured overrides /// applied. - pub fn build(self) -> Arc { + pub fn build(mut self) -> Arc { let flat: Arc = if let Some(flat) = self.flat_strategy { flat } else { @@ -332,36 +348,40 @@ impl WriteStrategyBuilder { let row_block_size = NonZeroUsize::new(self.row_block_size).vortex_expect("must be non 0"); - // 2. calculate stats for each row group - let stats = ZonedStrategy::new( - dict, - compress_then_flat.clone(), - ZonedLayoutOptions { - block_size: row_block_size, - ..Default::default() - }, - ); - - // 1. repartition each column to fixed row counts - let repartition = RepartitionStrategy::new( - stats, - RepartitionWriterOptions { - // No minimum block size in bytes - block_size_minimum: 0, - // Always repartition into 8K row blocks - block_len_multiple: self.row_block_size, - block_size_target: None, - canonicalize: false, - }, - ); + let column_writer = |options: ZonedLayoutOptions| -> Arc { + // 2. calculate stats for each row group + let stats = + ZonedStrategy::new(dict.clone(), compress_then_flat.clone(), options.clone()); + + // 1. repartition each column to fixed row counts + Arc::new(RepartitionStrategy::new( + stats, + RepartitionWriterOptions { + // No minimum block size in bytes + block_size_minimum: 0, + block_len_multiple: options.block_size.get(), + block_size_target: None, + canonicalize: false, + }, + )) + }; + let repartition = column_writer(ZonedLayoutOptions { + block_size: row_block_size, + ..Default::default() + }); + + for (field, options) in self.field_zoned_options { + self.field_writers + .entry(field) + .or_insert_with(|| column_writer(options)); + } // 0. start with splitting columns let validity_strategy = CollectStrategy::new(compress_then_flat.clone()); // Take any field overrides from the builder and apply them to the final strategy. - let mut table_strategy = - TableStrategy::new(Arc::new(validity_strategy), Arc::new(repartition)) - .with_field_writers(self.field_writers); + let mut table_strategy = TableStrategy::new(Arc::new(validity_strategy), repartition) + .with_field_writers(self.field_writers); if self.use_list_layout { // We need a closure here to enable recursive application of list layout. diff --git a/vortex-file/tests/experimental_bloom.rs b/vortex-file/tests/experimental_bloom.rs new file mode 100644 index 00000000000..6b505566c78 --- /dev/null +++ b/vortex-file/tests/experimental_bloom.rs @@ -0,0 +1,632 @@ +#![allow(clippy::expect_used)] +#![allow(clippy::tests_outside_test_module)] +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +//! End-to-end correctness evidence for the experimental zoned bloom skipping index. + +use std::num::NonZeroU8; +use std::num::NonZeroUsize; +use std::sync::Arc; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; +use std::time::Duration; +use std::time::Instant; + +use futures::FutureExt; +use parking_lot::Mutex; +use vortex_array::ArrayRef; +use vortex_array::IntoArray; +use vortex_array::VortexSessionExecute; +use vortex_array::arrays::ChunkedArray; +use vortex_array::arrays::PrimitiveArray; +use vortex_array::arrays::StructArray; +use vortex_array::arrays::VarBinViewArray; +use vortex_array::assert_arrays_eq; +use vortex_array::dtype::DType; +use vortex_array::dtype::Nullability; +use vortex_array::dtype::PType; +use vortex_array::expr::Expression; +use vortex_array::expr::eq; +use vortex_array::expr::get_item; +use vortex_array::expr::lit; +use vortex_array::expr::root; +use vortex_array::field_path; +use vortex_array::scalar_fn::ScalarFnVTableExt; +use vortex_array::scalar_fn::fns::like::Like; +use vortex_array::scalar_fn::fns::like::LikeOptions; +use vortex_array::stream::ArrayStreamExt; +use vortex_error::VortexResult; +use vortex_file::OpenOptionsSessionExt; +use vortex_file::WriteOptionsSessionExt; +use vortex_file::WriteStrategyBuilder; +use vortex_io::session::RuntimeSession; +use vortex_layout::LayoutStrategy; +use vortex_layout::layouts::chunked::writer::ChunkedLayoutStrategy; +use vortex_layout::layouts::flat::writer::FlatLayoutStrategy; +use vortex_layout::layouts::table::TableStrategy; +use vortex_layout::layouts::zoned::experimental::BloomOptions; +use vortex_layout::layouts::zoned::experimental::BloomSkipIndex; +use vortex_layout::layouts::zoned::experimental::NGramBloomSkipIndex; +use vortex_layout::layouts::zoned::experimental::SkipIndex; +use vortex_layout::layouts::zoned::writer::ZonedLayoutOptions; +use vortex_layout::layouts::zoned::writer::ZonedStrategy; +use vortex_layout::segments::SegmentFuture; +use vortex_layout::segments::SegmentId; +use vortex_layout::segments::SegmentSource; +use vortex_layout::session::LayoutSession; +use vortex_mask::Mask; +use vortex_session::VortexSession; +use vortex_utils::aliases::hash_set::HashSet; + +const ZONE_LEN: usize = 256; +const NZONES: usize = 4; +const HIT: i64 = 502; +const MISS: i64 = 503; + +fn bloom() -> BloomSkipIndex { + BloomSkipIndex::new(BloomOptions::new( + NonZeroUsize::new(1024).expect("1024 is non-zero"), + NonZeroU8::new(5).expect("5 is non-zero"), + )) +} + +fn session(index: Option<&dyn SkipIndex>) -> VortexSession { + let session = vortex_array::array_session() + .with::() + .with::(); + vortex_file::register_default_encodings(&session); + if let Some(index) = index { + index.register(&session); + } + session +} + +fn data() -> ArrayRef { + data_with_shape(ZONE_LEN, NZONES, Some(MISS)) +} + +fn data_with_shape(zone_len: usize, nzones: usize, missing: Option) -> ArrayRef { + let chunks = (0..nzones) + .map(|zone| { + let mut values = (0..zone_len) + .map(|row| i64::try_from(row * nzones + zone).expect("test value fits i64")) + .collect::>(); + if let Some(missing) = missing + && usize::try_from(missing).expect("missing value is non-negative") % nzones == zone + { + // Leave a hole inside every zone's min/max range so a MISS cannot be pruned by the + // ordinary range stats. The bloom must provide the proof. + values[usize::try_from(missing).expect("missing value is non-negative") / nzones] = + i64::try_from(zone_len * nzones + zone).expect("replacement fits i64"); + } + StructArray::from_fields(&[("id", PrimitiveArray::from_iter(values).into_array())]) + .expect("valid test struct") + .into_array() + }) + .collect::>(); + ChunkedArray::try_new( + chunks, + DType::struct_( + [("id", DType::Primitive(PType::I64, Nullability::NonNullable))], + Nullability::NonNullable, + ), + ) + .expect("valid chunked test data") + .into_array() +} + +fn low_card_data(zone_len: usize, nzones: usize, cardinality: i64) -> ArrayRef { + let chunks = (0..nzones) + .map(|_| { + StructArray::from_fields(&[( + "id", + PrimitiveArray::from_iter((0..zone_len).map(|row| row as i64 % cardinality)) + .into_array(), + )]) + .expect("valid low-cardinality struct") + .into_array() + }) + .collect::>(); + ChunkedArray::try_new( + chunks, + DType::struct_( + [("id", DType::Primitive(PType::I64, Nullability::NonNullable))], + Nullability::NonNullable, + ), + ) + .expect("valid low-cardinality chunked data") + .into_array() +} + +fn filter(value: i64) -> Expression { + eq(get_item("id", root()), lit(value)) +} + +fn like_filter(pattern: &str) -> Expression { + Like.new_expr( + LikeOptions::default(), + [get_item("text", root()), lit(pattern)], + ) +} + +fn strategy( + session: &VortexSession, + index: Option<&dyn SkipIndex>, + zone_len: usize, +) -> VortexResult> { + let mut options = ZonedLayoutOptions { + block_size: NonZeroUsize::new(zone_len).expect("zone length is non-zero"), + ..Default::default() + }; + if let Some(index) = index { + options = options.with_skip_index(index, &PType::I64.into(), session)?; + } + let zoned = ZonedStrategy::new( + ChunkedLayoutStrategy::new(FlatLayoutStrategy::default()), + FlatLayoutStrategy::default(), + options, + ); + let flat: Arc = Arc::new(FlatLayoutStrategy::default()); + Ok(Arc::new( + TableStrategy::new(Arc::clone(&flat), flat) + .with_field_writer(field_path!(id), Arc::new(zoned)), + )) +} + +async fn scan(file: &vortex_file::VortexFile, value: i64) -> VortexResult { + scan_expression(file, filter(value)).await +} + +async fn scan_expression( + file: &vortex_file::VortexFile, + filter: Expression, +) -> VortexResult { + file.scan()? + .with_filter(filter) + .into_array_stream()? + .read_all() + .await +} + +async fn write_file( + session: &VortexSession, + input: &ArrayRef, + index: Option<&dyn SkipIndex>, + zone_len: usize, +) -> VortexResult> { + let mut bytes = Vec::new(); + session + .write_options() + .with_strategy(strategy(session, index, zone_len)?) + .write(&mut bytes, input.to_array_stream()) + .await?; + Ok(bytes) +} + +fn ngram_data() -> ArrayRef { + let chunks = [ + ["alpha google result", "alpha", "search", "result"], + ["beta page", "beta", "another", "page"], + ["vortex storage", "columnar", "format", "rust"], + ["omega", "ending", "document", "text"], + ] + .into_iter() + .map(|values| { + StructArray::from_fields(&[("text", VarBinViewArray::from_iter_str(values).into_array())]) + .expect("valid string test struct") + .into_array() + }) + .collect::>(); + ChunkedArray::try_new( + chunks, + DType::struct_( + [("text", DType::Utf8(Nullability::NonNullable))], + Nullability::NonNullable, + ), + ) + .expect("valid chunked string data") + .into_array() +} + +async fn write_ngram_file( + session: &VortexSession, + input: &ArrayRef, + index: &NGramBloomSkipIndex, +) -> VortexResult> { + let options = ZonedLayoutOptions { + block_size: NonZeroUsize::new(4).expect("zone length is non-zero"), + ..Default::default() + } + .with_skip_index(index, &DType::Utf8(Nullability::NonNullable), session)?; + let strategy = WriteStrategyBuilder::default() + .with_field_zoned_options(field_path!(text), options) + .build(); + let mut bytes = Vec::new(); + session + .write_options() + .with_strategy(strategy) + .write(&mut bytes, input.to_array_stream()) + .await?; + Ok(bytes) +} + +#[tokio::test] +async fn bloom_roundtrip_prunes_and_unknown_reader_matches_full_scan() -> VortexResult<()> { + let index = bloom(); + let write_session = session(Some(&index)); + let input = data(); + let bytes = write_file(&write_session, &input, Some(&index), ZONE_LEN).await?; + + // Fresh registered reader: prove the exact zones kept by both a hit and a miss. + let read_session = session(Some(&index)); + let file = read_session.open_options().open_buffer(bytes.clone())?; + let reader = file.layout_reader()?; + let row_count = file.row_count(); + + let hit_mask = reader + .pruning_evaluation( + &(0..row_count), + &filter(HIT), + Mask::new_true(usize::try_from(row_count)?), + )? + .await?; + assert_eq!(hit_mask.true_count(), ZONE_LEN); + assert!(hit_mask.iter().take(2 * ZONE_LEN).all(|keep| !keep)); + assert!( + hit_mask + .iter() + .skip(2 * ZONE_LEN) + .take(ZONE_LEN) + .all(|keep| keep) + ); + assert!(hit_mask.iter().skip(3 * ZONE_LEN).all(|keep| !keep)); + + let miss_mask = reader + .pruning_evaluation( + &(0..row_count), + &filter(MISS), + Mask::new_true(usize::try_from(row_count)?), + )? + .await?; + assert!( + miss_mask.all_false(), + "an absent value should prune every zone" + ); + + // Fresh unregistered reader: #8904's allow-unknown path bypasses the zone map and therefore + // supplies the full-scan reference result instead of making the custom index a hard dependency. + let full_scan_session = session(None); + full_scan_session.allow_unknown(); + let full_scan_file = full_scan_session.open_options().open_buffer(bytes)?; + + let indexed_hit = scan(&file, HIT).await?; + let full_scan_hit = scan(&full_scan_file, HIT).await?; + assert_arrays_eq!( + indexed_hit, + full_scan_hit, + &mut read_session.create_execution_ctx() + ); + let expected_hit = + StructArray::from_fields(&[("id", PrimitiveArray::from_iter([HIT]).into_array())])? + .into_array(); + assert_arrays_eq!( + full_scan_hit, + expected_hit, + &mut read_session.create_execution_ctx() + ); + + let indexed_miss = scan(&file, MISS).await?; + let full_scan_miss = scan(&full_scan_file, MISS).await?; + assert_arrays_eq!( + indexed_miss, + full_scan_miss, + &mut read_session.create_execution_ctx() + ); + assert_eq!(full_scan_miss.len(), 0); + Ok(()) +} + +#[tokio::test] +async fn ngram_roundtrip_prunes_substring_like_and_matches_full_scan() -> VortexResult<()> { + let index = NGramBloomSkipIndex::default(); + let write_session = session(Some(&index)); + let input = ngram_data(); + let bytes = write_ngram_file(&write_session, &input, &index).await?; + + let read_session = session(Some(&index)); + let file = read_session.open_options().open_buffer(bytes.clone())?; + let reader = file.layout_reader()?; + let row_count = file.row_count(); + + let hit_mask = reader + .pruning_evaluation( + &(0..row_count), + &like_filter("%google%"), + Mask::new_true(usize::try_from(row_count)?), + )? + .await?; + assert!(hit_mask.iter().take(4).all(|keep| keep)); + assert!(hit_mask.iter().skip(4).all(|keep| !keep)); + + let miss_mask = reader + .pruning_evaluation( + &(0..row_count), + &like_filter("%missing-needle%"), + Mask::new_true(usize::try_from(row_count)?), + )? + .await?; + assert!( + miss_mask.all_false(), + "an absent substring should prune every zone" + ); + + let full_scan_session = session(None); + full_scan_session.allow_unknown(); + let full_scan_file = full_scan_session.open_options().open_buffer(bytes)?; + + let indexed_hit = scan_expression(&file, like_filter("%google%")).await?; + let full_scan_hit = scan_expression(&full_scan_file, like_filter("%google%")).await?; + assert_arrays_eq!( + indexed_hit, + full_scan_hit, + &mut read_session.create_execution_ctx() + ); + assert_eq!(indexed_hit.len(), 1); + + let indexed_miss = scan_expression(&file, like_filter("%missing-needle%")).await?; + let full_scan_miss = scan_expression(&full_scan_file, like_filter("%missing-needle%")).await?; + assert_arrays_eq!( + indexed_miss, + full_scan_miss, + &mut read_session.create_execution_ctx() + ); + assert_eq!(indexed_miss.len(), 0); + Ok(()) +} + +#[derive(Default)] +struct ReadCounts { + requests: AtomicU64, + bytes: AtomicU64, + segment_ids: Mutex>, +} + +struct CountingSegmentSource { + inner: Arc, + counts: Arc, +} + +impl CountingSegmentSource { + fn new(inner: Arc) -> (Self, Arc) { + let counts = Arc::new(ReadCounts::default()); + ( + Self { + inner, + counts: Arc::clone(&counts), + }, + counts, + ) + } +} + +impl SegmentSource for CountingSegmentSource { + fn request(&self, id: SegmentId) -> SegmentFuture { + let future = self.inner.request(id); + let counts = Arc::clone(&self.counts); + async move { + let buffer = future.await?; + counts.requests.fetch_add(1, Ordering::Relaxed); + counts + .bytes + .fetch_add(buffer.len() as u64, Ordering::Relaxed); + counts.segment_ids.lock().insert(*id); + Ok(buffer) + } + .boxed() + } +} + +struct BenchRun { + elapsed: Duration, + requests: u64, + segments: usize, + bytes: u64, + rows: usize, +} + +async fn benchmark_scan( + bytes: &[u8], + session: &VortexSession, + query: i64, +) -> VortexResult { + let file = session.open_options().open_buffer(bytes.to_vec())?; + let (source, counts) = CountingSegmentSource::new(file.segment_source()); + let file = file.with_segment_source(Arc::new(source)); + let start = Instant::now(); + let result = scan(&file, query).await?; + let elapsed = start.elapsed(); + Ok(BenchRun { + elapsed, + requests: counts.requests.load(Ordering::Relaxed), + segments: counts.segment_ids.lock().len(), + bytes: counts.bytes.load(Ordering::Relaxed), + rows: result.len(), + }) +} + +fn median(runs: &mut [BenchRun]) -> &BenchRun { + runs.sort_by_key(|run| run.elapsed); + &runs[runs.len() / 2] +} + +/// Focused spike benchmark. Run with: +/// +/// `cargo test --release -p vortex-file --test experimental_bloom bloom_point_lookup_benchmark +/// -- --ignored --nocapture` +#[tokio::test] +#[ignore = "release-only experimental benchmark"] +async fn bloom_point_lookup_benchmark() -> VortexResult<()> { + const BENCH_ZONE_LEN: usize = 8192; + const BENCH_NZONES: usize = 256; + const ITERATIONS: usize = 9; + const WARMUPS: usize = 2; + + let query = + i64::try_from((BENCH_ZONE_LEN / 2) * BENCH_NZONES + 17).expect("benchmark query fits i64"); + let input = data_with_shape(BENCH_ZONE_LEN, BENCH_NZONES, None); + let index = BloomSkipIndex::default(); + let baseline_session = session(None); + let indexed_session = session(Some(&index)); + + let baseline_bytes = write_file(&baseline_session, &input, None, BENCH_ZONE_LEN).await?; + let indexed_bytes = write_file(&indexed_session, &input, Some(&index), BENCH_ZONE_LEN).await?; + + // Pruning diagnostics are separate from timing so reading the zone map here cannot warm the + // timed reader. The query lies inside every zone's min/max range by construction. + let diagnostic_file = indexed_session + .open_options() + .open_buffer(indexed_bytes.clone())?; + let diagnostic_mask = diagnostic_file + .layout_reader()? + .pruning_evaluation( + &(0..diagnostic_file.row_count()), + &filter(query), + Mask::new_true(usize::try_from(diagnostic_file.row_count())?), + )? + .await?; + let zones_kept = diagnostic_mask.true_count() / BENCH_ZONE_LEN; + let zones_pruned = BENCH_NZONES - zones_kept; + assert!( + zones_pruned >= BENCH_NZONES - 16, + "the selective bloom benchmark should prune almost every zone" + ); + + for _ in 0..WARMUPS { + let baseline = benchmark_scan(&baseline_bytes, &baseline_session, query).await?; + let indexed = benchmark_scan(&indexed_bytes, &indexed_session, query).await?; + assert_eq!(baseline.rows, 1); + assert_eq!(indexed.rows, 1); + } + + let mut baseline_runs = Vec::with_capacity(ITERATIONS); + let mut indexed_runs = Vec::with_capacity(ITERATIONS); + for _ in 0..ITERATIONS { + baseline_runs.push(benchmark_scan(&baseline_bytes, &baseline_session, query).await?); + indexed_runs.push(benchmark_scan(&indexed_bytes, &indexed_session, query).await?); + } + + let baseline = median(&mut baseline_runs); + let indexed = median(&mut indexed_runs); + println!( + "bloom-bench rows={} zones={} zone_len={} query={} zones_pruned={} zones_kept={}", + input.len(), + BENCH_NZONES, + BENCH_ZONE_LEN, + query, + zones_pruned, + zones_kept + ); + println!( + "bloom-bench baseline file_bytes={} median_ms={:.3} requests={} segments={} segment_bytes={}", + baseline_bytes.len(), + baseline.elapsed.as_secs_f64() * 1000.0, + baseline.requests, + baseline.segments, + baseline.bytes + ); + println!( + "bloom-bench indexed file_bytes={} median_ms={:.3} requests={} segments={} segment_bytes={}", + indexed_bytes.len(), + indexed.elapsed.as_secs_f64() * 1000.0, + indexed.requests, + indexed.segments, + indexed.bytes + ); + println!( + "bloom-bench speedup={:.3}x byte_reduction={:.3}x", + baseline.elapsed.as_secs_f64() / indexed.elapsed.as_secs_f64(), + baseline.bytes as f64 / indexed.bytes as f64 + ); + + // Negative control: a low-cardinality equality present in every zone cannot skip any data. + let low_card_input = low_card_data(BENCH_ZONE_LEN, BENCH_NZONES, 16); + let low_card_query = 7; + let low_card_baseline_bytes = + write_file(&baseline_session, &low_card_input, None, BENCH_ZONE_LEN).await?; + let low_card_indexed_bytes = write_file( + &indexed_session, + &low_card_input, + Some(&index), + BENCH_ZONE_LEN, + ) + .await?; + + let low_card_diagnostic_file = indexed_session + .open_options() + .open_buffer(low_card_indexed_bytes.clone())?; + let low_card_mask = low_card_diagnostic_file + .layout_reader()? + .pruning_evaluation( + &(0..low_card_diagnostic_file.row_count()), + &filter(low_card_query), + Mask::new_true(usize::try_from(low_card_diagnostic_file.row_count())?), + )? + .await?; + let low_card_zones_kept = low_card_mask.true_count() / BENCH_ZONE_LEN; + assert_eq!( + low_card_zones_kept, BENCH_NZONES, + "a value present in every zone cannot be pruned" + ); + + for _ in 0..WARMUPS { + let baseline = + benchmark_scan(&low_card_baseline_bytes, &baseline_session, low_card_query).await?; + let indexed = + benchmark_scan(&low_card_indexed_bytes, &indexed_session, low_card_query).await?; + assert_eq!(baseline.rows, low_card_input.len() / 16); + assert_eq!(indexed.rows, baseline.rows); + } + + let mut low_card_baseline_runs = Vec::with_capacity(ITERATIONS); + let mut low_card_indexed_runs = Vec::with_capacity(ITERATIONS); + for _ in 0..ITERATIONS { + low_card_baseline_runs.push( + benchmark_scan(&low_card_baseline_bytes, &baseline_session, low_card_query).await?, + ); + low_card_indexed_runs + .push(benchmark_scan(&low_card_indexed_bytes, &indexed_session, low_card_query).await?); + } + let low_card_baseline = median(&mut low_card_baseline_runs); + let low_card_indexed = median(&mut low_card_indexed_runs); + println!( + "bloom-bench-low-card rows={} zones={} query={} zones_pruned={} zones_kept={}", + low_card_input.len(), + BENCH_NZONES, + low_card_query, + BENCH_NZONES - low_card_zones_kept, + low_card_zones_kept + ); + println!( + "bloom-bench-low-card baseline file_bytes={} median_ms={:.3} requests={} segments={} segment_bytes={}", + low_card_baseline_bytes.len(), + low_card_baseline.elapsed.as_secs_f64() * 1000.0, + low_card_baseline.requests, + low_card_baseline.segments, + low_card_baseline.bytes + ); + println!( + "bloom-bench-low-card indexed file_bytes={} median_ms={:.3} requests={} segments={} segment_bytes={}", + low_card_indexed_bytes.len(), + low_card_indexed.elapsed.as_secs_f64() * 1000.0, + low_card_indexed.requests, + low_card_indexed.segments, + low_card_indexed.bytes + ); + println!( + "bloom-bench-low-card speedup={:.3}x byte_reduction={:.3}x", + low_card_baseline.elapsed.as_secs_f64() / low_card_indexed.elapsed.as_secs_f64(), + low_card_baseline.bytes as f64 / low_card_indexed.bytes as f64 + ); + Ok(()) +} diff --git a/vortex-layout/src/layouts/zoned/experimental.rs b/vortex-layout/src/layouts/zoned/experimental.rs new file mode 100644 index 00000000000..d60062b7382 --- /dev/null +++ b/vortex-layout/src/layouts/zoned/experimental.rs @@ -0,0 +1,593 @@ +//! Experimental skipping-index interface and bloom-filter implementation. +//! +//! This module is intentionally a spike. Its APIs are not stable and its bloom format is not a +//! compatibility commitment. + +mod ngram; + +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors +use std::fmt; +use std::fmt::Debug; +use std::fmt::Display; +use std::fmt::Formatter; +use std::num::NonZeroU8; +use std::num::NonZeroUsize; +use std::sync::Arc; + +pub use ngram::NGramBloomOptions; +pub use ngram::NGramBloomSkipIndex; +use vortex_array::ArrayRef; +use vortex_array::Columnar; +use vortex_array::ExecutionCtx; +use vortex_array::IntoArray; +use vortex_array::aggregate_fn::AggregateFnId; +use vortex_array::aggregate_fn::AggregateFnRef; +use vortex_array::aggregate_fn::AggregateFnVTable; +use vortex_array::aggregate_fn::AggregateFnVTableExt; +use vortex_array::aggregate_fn::session::AggregateFnSessionExt; +use vortex_array::arrays::BoolArray; +use vortex_array::arrays::ConstantArray; +use vortex_array::arrays::VarBinViewArray; +use vortex_array::arrays::varbinview::VarBinViewArrayExt; +use vortex_array::dtype::DType; +use vortex_array::dtype::Nullability; +use vortex_array::dtype::PType; +use vortex_array::expr::Expression; +use vortex_array::expr::is_root; +use vortex_array::expr::not; +use vortex_array::scalar::Scalar; +use vortex_array::scalar_fn::Arity; +use vortex_array::scalar_fn::ChildName; +use vortex_array::scalar_fn::ExecutionArgs; +use vortex_array::scalar_fn::ScalarFnId; +use vortex_array::scalar_fn::ScalarFnVTable; +use vortex_array::scalar_fn::ScalarFnVTableExt; +use vortex_array::scalar_fn::fns::binary::Binary; +use vortex_array::scalar_fn::fns::literal::Literal; +use vortex_array::scalar_fn::fns::operators::Operator; +use vortex_array::scalar_fn::session::ScalarFnSessionExt; +use vortex_array::stats::rewrite::StatsRewriteCtx; +use vortex_array::stats::rewrite::StatsRewriteRule; +use vortex_array::stats::session::StatsSessionExt; +use vortex_array::stats::stat; +use vortex_buffer::BitBuffer; +use vortex_error::VortexResult; +use vortex_error::vortex_ensure; +use vortex_error::vortex_err; +use vortex_session::VortexSession; +use vortex_session::registry::CachedId; + +use super::writer::ZonedLayoutOptions; +use super::writer::default_zoned_aggregate_fns; + +/// One definition that supplies a persisted aggregate and registers every read-side component +/// needed to consult it. +/// +/// The writer helper [`ZonedLayoutOptions::with_skip_index`] is the explicit per-column declaration +/// seam. Readers call [`SkipIndex::register`] on their fresh session before opening the file. +pub trait SkipIndex: Debug + Send + Sync + 'static { + /// The aggregate state to persist for `input_dtype`, or `None` when unsupported. + fn aggregate_fn(&self, input_dtype: &DType) -> Option; + + /// Register the aggregate, optional probe function, and predicate rewrite as one operation. + fn register(&self, session: &VortexSession); +} + +impl ZonedLayoutOptions { + /// Add `index` to this zoned writer while retaining the default min/max-style aggregates. + /// + /// A `ZonedStrategy` configured with these options can be installed for one field with + /// `TableStrategy::with_field_writer`, which is the spike's write-time "index column X" API. + pub fn with_skip_index( + mut self, + index: &I, + input_dtype: &DType, + session: &VortexSession, + ) -> VortexResult { + let aggregate_fn = index + .aggregate_fn(input_dtype) + .ok_or_else(|| vortex_err!("skip index does not support input dtype {input_dtype}"))?; + + let mut aggregate_fns = self + .aggregate_fns + .take() + .unwrap_or_else(|| default_zoned_aggregate_fns(input_dtype, session)) + .to_vec(); + if !aggregate_fns.iter().any(|stored| stored == &aggregate_fn) { + aggregate_fns.push(aggregate_fn); + } + self.aggregate_fns = Some(Arc::from(aggregate_fns)); + Ok(self) + } +} + +/// Bloom-filter tuning persisted as aggregate metadata. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct BloomOptions { + bytes: NonZeroUsize, + hashes: NonZeroU8, +} + +impl BloomOptions { + /// Create bloom options with a fixed number of bytes and hash probes per zone. + pub fn new(bytes: NonZeroUsize, hashes: NonZeroU8) -> Self { + Self { bytes, hashes } + } + + /// Bytes stored for each zone. + pub fn bytes(&self) -> NonZeroUsize { + self.bytes + } + + /// Hash probes performed for each inserted or tested value. + pub fn hashes(&self) -> NonZeroU8 { + self.hashes + } +} + +impl Default for BloomOptions { + fn default() -> Self { + Self { + // Eight bits per row at the default 8192-row zone size. + bytes: NonZeroUsize::new(8192).unwrap_or(NonZeroUsize::MIN), + hashes: NonZeroU8::new(5).unwrap_or(NonZeroU8::MIN), + } + } +} + +impl Display for BloomOptions { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "bytes={},hashes={}", self.bytes, self.hashes) + } +} + +/// Experimental bloom skipping index for `i64` equality predicates. +#[derive(Clone, Debug, Default)] +pub struct BloomSkipIndex { + options: BloomOptions, +} + +impl BloomSkipIndex { + /// Create an index with explicit bloom tuning. + pub fn new(options: BloomOptions) -> Self { + Self { options } + } + + /// The persisted bloom options. + pub fn options(&self) -> &BloomOptions { + &self.options + } +} + +impl SkipIndex for BloomSkipIndex { + fn aggregate_fn(&self, input_dtype: &DType) -> Option { + BloomFilter + .return_dtype(&self.options, input_dtype) + .map(|_| BloomFilter.bind(self.options.clone())) + } + + fn register(&self, session: &VortexSession) { + session.aggregate_fns().register(BloomFilter); + session.scalar_fns().register(BloomContains); + session.stats().register_rewrite(BloomEqRewrite { + options: self.options.clone(), + }); + } +} + +/// Aggregate that stores one fixed-size bloom bitset as a `Binary` scalar for every zone. +#[derive(Clone, Debug)] +pub struct BloomFilter; + +/// In-memory bloom accumulator. Only the bitset is persisted. +pub struct BloomPartial { + pub(super) bits: Vec, + pub(super) hashes: u8, + pub(super) gram_size: Option, +} + +impl AggregateFnVTable for BloomFilter { + type Options = BloomOptions; + type Partial = BloomPartial; + + fn id(&self) -> AggregateFnId { + static ID: CachedId = CachedId::new("vortex.experimental.bloom_filter.i64.v1"); + *ID + } + + fn serialize(&self, options: &Self::Options) -> VortexResult>> { + let bytes = u32::try_from(options.bytes.get())?; + let mut metadata = bytes.to_le_bytes().to_vec(); + metadata.push(options.hashes.get()); + Ok(Some(metadata)) + } + + fn deserialize( + &self, + metadata: &[u8], + _session: &VortexSession, + ) -> VortexResult { + vortex_ensure!(metadata.len() == 5, "invalid bloom metadata length"); + let bytes = u32::from_le_bytes([metadata[0], metadata[1], metadata[2], metadata[3]]); + Ok(BloomOptions::new( + NonZeroUsize::new(bytes as usize) + .ok_or_else(|| vortex_err!("bloom byte length must be non-zero"))?, + NonZeroU8::new(metadata[4]) + .ok_or_else(|| vortex_err!("bloom hash count must be non-zero"))?, + )) + } + + fn return_dtype(&self, _options: &Self::Options, input_dtype: &DType) -> Option { + matches!(input_dtype, DType::Primitive(PType::I64, _)) + .then_some(DType::Binary(Nullability::NonNullable)) + } + + fn partial_dtype(&self, options: &Self::Options, input_dtype: &DType) -> Option { + self.return_dtype(options, input_dtype) + } + + fn empty_partial( + &self, + options: &Self::Options, + _input_dtype: &DType, + ) -> VortexResult { + Ok(BloomPartial { + bits: vec![0; options.bytes.get()], + hashes: options.hashes.get(), + gram_size: None, + }) + } + + fn combine_partials(&self, partial: &mut Self::Partial, other: Scalar) -> VortexResult<()> { + if other.is_null() { + return Ok(()); + } + let other = other + .as_binary() + .value() + .ok_or_else(|| vortex_err!("non-null bloom partial has no bytes"))?; + vortex_ensure!( + partial.bits.len() == other.len(), + "bloom partial length mismatch" + ); + for (dst, src) in partial.bits.iter_mut().zip(other.as_slice()) { + *dst |= *src; + } + Ok(()) + } + + fn to_scalar(&self, partial: &Self::Partial) -> VortexResult { + Ok(Scalar::binary( + partial.bits.clone(), + Nullability::NonNullable, + )) + } + + fn reset(&self, partial: &mut Self::Partial) { + partial.bits.fill(0); + } + + fn is_saturated(&self, partial: &Self::Partial) -> bool { + partial.bits.iter().all(|byte| *byte == u8::MAX) + } + + fn accumulate( + &self, + partial: &mut Self::Partial, + batch: &Columnar, + ctx: &mut ExecutionCtx, + ) -> VortexResult<()> { + match batch { + Columnar::Constant(constant) => { + if let Some(value) = i64_value(constant.scalar())? { + bloom_insert(&mut partial.bits, value, partial.hashes); + } + } + Columnar::Canonical(canonical) => { + let primitive = canonical.as_primitive(); + let values = primitive.as_slice::(); + let validity = primitive.validity()?.execute_mask(values.len(), ctx)?; + for (&value, valid) in values.iter().zip(validity.iter()) { + if valid { + bloom_insert(&mut partial.bits, value, partial.hashes); + } + } + } + } + Ok(()) + } + + fn finalize(&self, partials: ArrayRef) -> VortexResult { + Ok(partials) + } + + fn finalize_scalar(&self, partial: &Self::Partial) -> VortexResult { + self.to_scalar(partial) + } +} + +/// Probe scalar function: test one `i64` literal against each binary bloom state. +#[derive(Clone, Debug)] +pub struct BloomContains; + +impl ScalarFnVTable for BloomContains { + type Options = BloomOptions; + + fn id(&self) -> ScalarFnId { + static ID: CachedId = CachedId::new("vortex.experimental.bloom_contains.i64.v1"); + *ID + } + + fn serialize(&self, options: &Self::Options) -> VortexResult>> { + BloomFilter.serialize(options) + } + + fn deserialize(&self, metadata: &[u8], session: &VortexSession) -> VortexResult { + BloomFilter.deserialize(metadata, session) + } + + fn arity(&self, _options: &Self::Options) -> Arity { + Arity::Exact(2) + } + + fn child_name(&self, _options: &Self::Options, child_idx: usize) -> ChildName { + match child_idx { + 0 => ChildName::from("filter"), + 1 => ChildName::from("needle"), + _ => unreachable!("bloom_contains has exactly two children"), + } + } + + fn return_dtype(&self, _options: &Self::Options, args: &[DType]) -> VortexResult { + vortex_ensure!( + matches!(args[0], DType::Binary(_)), + "bloom filter must be Binary" + ); + vortex_ensure!( + matches!(args[1], DType::Primitive(PType::I64, _)), + "bloom needle must be i64" + ); + Ok(DType::Bool(args[0].nullability() | args[1].nullability())) + } + + fn execute( + &self, + options: &Self::Options, + args: &dyn ExecutionArgs, + ctx: &mut ExecutionCtx, + ) -> VortexResult { + let filters = args.get(0)?.execute::(ctx)?; + let needle_array = args.get(1)?; + let needle = needle_array + .as_constant() + .ok_or_else(|| vortex_err!("bloom needle must be constant"))?; + let Some(needle) = i64_value(&needle)? else { + return Ok(ConstantArray::new( + Scalar::null(DType::Bool(Nullability::Nullable)), + args.row_count(), + ) + .into_array()); + }; + + let validity = filters.varbinview_validity(); + let valid = validity.execute_mask(filters.len(), ctx)?; + for (idx, is_valid) in valid.iter().enumerate() { + if is_valid { + vortex_ensure!( + filters.bytes_at(idx).len() == options.bytes.get(), + "stored bloom byte length does not match options" + ); + } + } + let bits = BitBuffer::from_iter(valid.iter().enumerate().map(|(idx, is_valid)| { + is_valid + && bloom_contains( + filters.bytes_at(idx).as_slice(), + needle, + options.hashes.get(), + ) + })); + Ok(BoolArray::new(bits, validity).into_array()) + } + + fn is_null_sensitive(&self, _options: &Self::Options) -> bool { + false + } + + fn is_fallible(&self, _options: &Self::Options) -> bool { + false + } +} + +/// Equality rewrite that turns a bloom miss into a zone falsifier. +#[derive(Clone, Debug)] +pub struct BloomEqRewrite { + options: BloomOptions, +} + +impl StatsRewriteRule for BloomEqRewrite { + fn scalar_fn_id(&self) -> ScalarFnId { + Binary.id() + } + + fn falsify( + &self, + expr: &Expression, + ctx: &StatsRewriteCtx<'_>, + ) -> VortexResult> { + if *expr.as_::() != Operator::Eq { + return Ok(None); + } + + let (column, literal) = if is_root(expr.child(0)) && expr.child(1).is::() { + (expr.child(0), expr.child(1)) + } else if is_root(expr.child(1)) && expr.child(0).is::() { + (expr.child(1), expr.child(0)) + } else { + return Ok(None); + }; + if !matches!(ctx.return_dtype(column)?, DType::Primitive(PType::I64, _)) + || literal.as_::().is_null() + { + return Ok(None); + } + + let filter = stat(column.clone(), BloomFilter.bind(self.options.clone())); + let contains = BloomContains.new_expr(self.options.clone(), [filter, literal.clone()]); + Ok(Some(not(contains))) + } +} + +fn i64_value(scalar: &Scalar) -> VortexResult> { + if scalar.is_null() { + return Ok(None); + } + scalar + .as_primitive_opt() + .and_then(|primitive| primitive.typed_value::()) + .map(Some) + .ok_or_else(|| vortex_err!("bloom value must be i64")) +} + +fn bloom_insert(bits: &mut [u8], value: i64, hashes: u8) { + bloom_insert_hash( + bits, + splitmix64(value as u64 ^ 0x243f_6a88_85a3_08d3), + hashes, + ); +} + +pub(super) fn bloom_insert_bytes(bits: &mut [u8], value: &[u8], hashes: u8) { + bloom_insert_hash(bits, stable_hash_bytes(value), hashes); +} + +fn bloom_insert_hash(bits: &mut [u8], hash: u64, hashes: u8) { + for (byte, bit) in bloom_positions(hash, bits.len(), hashes) { + bits[byte] |= 1 << bit; + } +} + +fn bloom_contains(bits: &[u8], value: i64, hashes: u8) -> bool { + bloom_contains_hash( + bits, + splitmix64(value as u64 ^ 0x243f_6a88_85a3_08d3), + hashes, + ) +} + +pub(super) fn bloom_contains_bytes(bits: &[u8], value: &[u8], hashes: u8) -> bool { + bloom_contains_hash(bits, stable_hash_bytes(value), hashes) +} + +fn bloom_contains_hash(bits: &[u8], hash: u64, hashes: u8) -> bool { + bloom_positions(hash, bits.len(), hashes).all(|(byte, bit)| bits[byte] & (1 << bit) != 0) +} + +fn bloom_positions(hash: u64, bytes: usize, hashes: u8) -> impl Iterator { + let h1 = hash; + let h2 = splitmix64(h1 ^ 0x1319_8a2e_0370_7344) | 1; + let bit_len = u64::try_from(bytes).unwrap_or(u64::MAX).saturating_mul(8); + (0..u64::from(hashes)).map(move |probe| { + let position = h1 + .wrapping_add(probe.wrapping_mul(h2)) + .wrapping_rem(bit_len); + // `position / 8` is less than `bytes`, which is already a `usize`. + let byte = usize::try_from(position / 8).unwrap_or_default(); + let bit = u32::try_from(position % 8).unwrap_or_default(); + (byte, bit) + }) +} + +fn stable_hash_bytes(value: &[u8]) -> u64 { + let hash = value.iter().fold(0xcbf2_9ce4_8422_2325, |hash, byte| { + (hash ^ u64::from(*byte)).wrapping_mul(0x0000_0100_0000_01b3) + }); + splitmix64(hash ^ 0x243f_6a88_85a3_08d3) +} + +fn splitmix64(mut value: u64) -> u64 { + value = value.wrapping_add(0x9e37_79b9_7f4a_7c15); + value = (value ^ (value >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + value = (value ^ (value >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + value ^ (value >> 31) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use vortex_array::IntoArray; + use vortex_array::VortexSessionExecute; + use vortex_array::aggregate_fn::Accumulator; + use vortex_array::aggregate_fn::DynAccumulator; + use vortex_array::arrays::PrimitiveArray; + use vortex_array::arrays::StructArray; + use vortex_array::dtype::Nullability; + use vortex_array::expr::eq; + use vortex_array::expr::lit; + use vortex_array::expr::root; + use vortex_array::validity::Validity; + use vortex_error::VortexResult; + + use super::*; + use crate::layouts::zoned::zone_map::ZoneMap; + + fn small_options() -> BloomOptions { + BloomOptions::new( + NonZeroUsize::new(64).expect("64 is non-zero"), + NonZeroU8::new(3).expect("3 is non-zero"), + ) + } + + #[test] + fn aggregate_roundtrips_options_and_membership() -> VortexResult<()> { + let session = vortex_array::array_session(); + let options = small_options(); + let metadata = BloomFilter + .serialize(&options)? + .expect("bloom is serializable"); + assert_eq!(BloomFilter.deserialize(&metadata, &session)?, options); + + let mut ctx = session.create_execution_ctx(); + let mut accumulator = Accumulator::try_new( + BloomFilter, + options.clone(), + DType::Primitive(PType::I64, Nullability::NonNullable), + )?; + accumulator.accumulate( + &PrimitiveArray::from_iter([10i64, 20, 30]).into_array(), + &mut ctx, + )?; + let state = accumulator.finish()?; + let bytes = state.as_binary().value().expect("bloom state is non-null"); + assert!(bloom_contains(bytes.as_slice(), 10, options.hashes.get())); + assert!(bloom_contains(bytes.as_slice(), 20, options.hashes.get())); + assert!(!bloom_contains(bytes.as_slice(), 999, options.hashes.get())); + Ok(()) + } + + #[test] + fn missing_stat_stays_inconclusive() -> VortexResult<()> { + let session = vortex_array::array_session(); + let index = BloomSkipIndex::new(small_options()); + index.register(&session); + let predicate = eq(root(), lit(42i64)); + let proof = predicate + .falsify( + &DType::Primitive(PType::I64, Nullability::NonNullable), + &session, + )? + .expect("equality has a bloom proof"); + + let zone_map = ZoneMap::try_new( + DType::Primitive(PType::I64, Nullability::NonNullable), + StructArray::try_new(Vec::<&str>::new().into(), vec![], 2, Validity::NonNullable)?, + Arc::new([]), + 8, + 16, + )?; + assert!(zone_map.prune(&proof, &session)?.all_false()); + Ok(()) + } +} diff --git a/vortex-layout/src/layouts/zoned/experimental/ngram.rs b/vortex-layout/src/layouts/zoned/experimental/ngram.rs new file mode 100644 index 00000000000..1ab57660e2c --- /dev/null +++ b/vortex-layout/src/layouts/zoned/experimental/ngram.rs @@ -0,0 +1,521 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +use std::fmt; +use std::fmt::Display; +use std::fmt::Formatter; +use std::num::NonZeroU8; + +use vortex_array::ArrayRef; +use vortex_array::Columnar; +use vortex_array::ExecutionCtx; +use vortex_array::IntoArray; +use vortex_array::aggregate_fn::AggregateFnId; +use vortex_array::aggregate_fn::AggregateFnRef; +use vortex_array::aggregate_fn::AggregateFnVTable; +use vortex_array::aggregate_fn::AggregateFnVTableExt; +use vortex_array::aggregate_fn::session::AggregateFnSessionExt; +use vortex_array::arrays::BoolArray; +use vortex_array::arrays::ConstantArray; +use vortex_array::arrays::VarBinViewArray; +use vortex_array::arrays::varbinview::VarBinViewArrayExt; +use vortex_array::dtype::DType; +use vortex_array::dtype::Nullability; +use vortex_array::expr::Expression; +use vortex_array::expr::is_root; +use vortex_array::expr::not; +use vortex_array::scalar::Scalar; +use vortex_array::scalar_fn::Arity; +use vortex_array::scalar_fn::ChildName; +use vortex_array::scalar_fn::ExecutionArgs; +use vortex_array::scalar_fn::ScalarFnId; +use vortex_array::scalar_fn::ScalarFnVTable; +use vortex_array::scalar_fn::ScalarFnVTableExt; +use vortex_array::scalar_fn::fns::like::Like; +use vortex_array::scalar_fn::fns::literal::Literal; +use vortex_array::scalar_fn::session::ScalarFnSessionExt; +use vortex_array::stats::rewrite::StatsRewriteCtx; +use vortex_array::stats::rewrite::StatsRewriteRule; +use vortex_array::stats::session::StatsSessionExt; +use vortex_array::stats::stat; +use vortex_buffer::BitBuffer; +use vortex_error::VortexResult; +use vortex_error::vortex_ensure; +use vortex_error::vortex_err; +use vortex_session::VortexSession; +use vortex_session::registry::CachedId; + +use super::BloomOptions; +use super::BloomPartial; +use super::SkipIndex; +use super::bloom_contains_bytes; +use super::bloom_insert_bytes; + +/// Persisted tuning for a bloom filter populated with every byte n-gram in a UTF-8 zone. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct NGramBloomOptions { + bloom: BloomOptions, + gram_size: NonZeroU8, +} + +impl NGramBloomOptions { + /// Create n-gram bloom options. + pub fn new(bloom: BloomOptions, gram_size: NonZeroU8) -> Self { + Self { bloom, gram_size } + } + + /// Bloom sizing and hash-count options. + pub fn bloom(&self) -> &BloomOptions { + &self.bloom + } + + /// Number of UTF-8 bytes in each indexed gram. + pub fn gram_size(&self) -> NonZeroU8 { + self.gram_size + } +} + +impl Default for NGramBloomOptions { + fn default() -> Self { + Self { + bloom: BloomOptions::new( + std::num::NonZeroUsize::new(64 * 1024).unwrap_or(std::num::NonZeroUsize::MIN), + NonZeroU8::new(5).unwrap_or(NonZeroU8::MIN), + ), + gram_size: NonZeroU8::new(3).unwrap_or(NonZeroU8::MIN), + } + } +} + +impl Display for NGramBloomOptions { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{},gram_size={}", self.bloom, self.gram_size) + } +} + +/// Experimental byte n-gram bloom index for case-sensitive SQL `LIKE` predicates. +#[derive(Clone, Debug, Default)] +pub struct NGramBloomSkipIndex { + options: NGramBloomOptions, +} + +impl NGramBloomSkipIndex { + /// Create an index with explicit bloom and gram tuning. + pub fn new(options: NGramBloomOptions) -> Self { + Self { options } + } + + /// Persisted index options. + pub fn options(&self) -> &NGramBloomOptions { + &self.options + } +} + +impl SkipIndex for NGramBloomSkipIndex { + fn aggregate_fn(&self, input_dtype: &DType) -> Option { + NGramBloomFilter + .return_dtype(&self.options, input_dtype) + .map(|_| NGramBloomFilter.bind(self.options.clone())) + } + + fn register(&self, session: &VortexSession) { + session.aggregate_fns().register(NGramBloomFilter); + session.scalar_fns().register(NGramBloomContains); + session.stats().register_rewrite(NGramLikeRewrite { + options: self.options.clone(), + }); + } +} + +#[derive(Clone, Debug)] +struct NGramBloomFilter; + +impl AggregateFnVTable for NGramBloomFilter { + type Options = NGramBloomOptions; + type Partial = BloomPartial; + + fn id(&self) -> AggregateFnId { + static ID: CachedId = CachedId::new("vortex.experimental.ngram_bloom.utf8.v1"); + *ID + } + + fn serialize(&self, options: &Self::Options) -> VortexResult>> { + let bytes = u32::try_from(options.bloom.bytes().get())?; + let mut metadata = bytes.to_le_bytes().to_vec(); + metadata.push(options.bloom.hashes().get()); + metadata.push(options.gram_size.get()); + Ok(Some(metadata)) + } + + fn deserialize( + &self, + metadata: &[u8], + _session: &VortexSession, + ) -> VortexResult { + vortex_ensure!(metadata.len() == 6, "invalid n-gram bloom metadata length"); + let bytes = u32::from_le_bytes([metadata[0], metadata[1], metadata[2], metadata[3]]); + Ok(NGramBloomOptions::new( + BloomOptions::new( + std::num::NonZeroUsize::new(usize::try_from(bytes)?) + .ok_or_else(|| vortex_err!("n-gram bloom byte length must be non-zero"))?, + NonZeroU8::new(metadata[4]) + .ok_or_else(|| vortex_err!("n-gram bloom hash count must be non-zero"))?, + ), + NonZeroU8::new(metadata[5]) + .ok_or_else(|| vortex_err!("n-gram size must be non-zero"))?, + )) + } + + fn return_dtype(&self, _options: &Self::Options, input_dtype: &DType) -> Option { + matches!(input_dtype, DType::Utf8(_)).then_some(DType::Binary(Nullability::NonNullable)) + } + + fn partial_dtype(&self, options: &Self::Options, input_dtype: &DType) -> Option { + self.return_dtype(options, input_dtype) + } + + fn empty_partial( + &self, + options: &Self::Options, + _input_dtype: &DType, + ) -> VortexResult { + Ok(BloomPartial { + bits: vec![0; options.bloom.bytes().get()], + hashes: options.bloom.hashes().get(), + gram_size: Some(options.gram_size.get()), + }) + } + + fn combine_partials(&self, partial: &mut Self::Partial, other: Scalar) -> VortexResult<()> { + if other.is_null() { + return Ok(()); + } + let other = other + .as_binary() + .value() + .ok_or_else(|| vortex_err!("non-null n-gram bloom partial has no bytes"))?; + vortex_ensure!( + partial.bits.len() == other.len(), + "n-gram bloom partial length mismatch" + ); + for (dst, src) in partial.bits.iter_mut().zip(other.as_slice()) { + *dst |= *src; + } + Ok(()) + } + + fn to_scalar(&self, partial: &Self::Partial) -> VortexResult { + Ok(Scalar::binary( + partial.bits.clone(), + Nullability::NonNullable, + )) + } + + fn reset(&self, partial: &mut Self::Partial) { + partial.bits.fill(0); + } + + fn is_saturated(&self, partial: &Self::Partial) -> bool { + partial.bits.iter().all(|byte| *byte == u8::MAX) + } + + fn accumulate( + &self, + partial: &mut Self::Partial, + batch: &Columnar, + ctx: &mut ExecutionCtx, + ) -> VortexResult<()> { + let gram_size = usize::from( + partial + .gram_size + .ok_or_else(|| vortex_err!("n-gram bloom partial is missing its gram size"))?, + ); + match batch { + Columnar::Constant(constant) => { + if let Some(value) = constant.scalar().as_utf8().value() { + insert_grams( + &mut partial.bits, + value.as_bytes(), + gram_size, + partial.hashes, + ); + } + } + Columnar::Canonical(canonical) => { + let values = canonical.as_varbinview(); + let validity = values + .varbinview_validity() + .execute_mask(values.len(), ctx)?; + for (idx, valid) in validity.iter().enumerate() { + if valid { + insert_grams( + &mut partial.bits, + values.bytes_at(idx).as_slice(), + gram_size, + partial.hashes, + ); + } + } + } + } + Ok(()) + } + + fn finalize(&self, partials: ArrayRef) -> VortexResult { + Ok(partials) + } + + fn finalize_scalar(&self, partial: &Self::Partial) -> VortexResult { + self.to_scalar(partial) + } +} + +#[derive(Clone, Debug)] +struct NGramBloomContains; + +impl ScalarFnVTable for NGramBloomContains { + type Options = NGramBloomOptions; + + fn id(&self) -> ScalarFnId { + static ID: CachedId = CachedId::new("vortex.experimental.ngram_bloom_contains.utf8.v1"); + *ID + } + + fn serialize(&self, options: &Self::Options) -> VortexResult>> { + NGramBloomFilter.serialize(options) + } + + fn deserialize(&self, metadata: &[u8], session: &VortexSession) -> VortexResult { + NGramBloomFilter.deserialize(metadata, session) + } + + fn arity(&self, _options: &Self::Options) -> Arity { + Arity::Exact(2) + } + + fn child_name(&self, _options: &Self::Options, child_idx: usize) -> ChildName { + match child_idx { + 0 => ChildName::from("filter"), + 1 => ChildName::from("pattern"), + _ => unreachable!("ngram_bloom_contains has exactly two children"), + } + } + + fn return_dtype(&self, _options: &Self::Options, args: &[DType]) -> VortexResult { + vortex_ensure!( + matches!(args[0], DType::Binary(_)), + "n-gram bloom must be Binary" + ); + vortex_ensure!( + matches!(args[1], DType::Utf8(_)), + "LIKE pattern must be Utf8" + ); + Ok(DType::Bool(args[0].nullability() | args[1].nullability())) + } + + fn execute( + &self, + options: &Self::Options, + args: &dyn ExecutionArgs, + ctx: &mut ExecutionCtx, + ) -> VortexResult { + let filters = args.get(0)?.execute::(ctx)?; + let pattern = args + .get(1)? + .as_constant() + .ok_or_else(|| vortex_err!("LIKE pattern must be constant"))?; + let Some(pattern) = pattern.as_utf8().value() else { + return Ok(ConstantArray::new( + Scalar::null(DType::Bool(Nullability::Nullable)), + args.row_count(), + ) + .into_array()); + }; + let grams = required_grams(pattern, usize::from(options.gram_size.get())); + + let validity = filters.varbinview_validity(); + let valid = validity.execute_mask(filters.len(), ctx)?; + let mut possible = Vec::with_capacity(filters.len()); + let mut set_bits = 0u64; + let mut valid_zones = 0usize; + for (idx, is_valid) in valid.iter().enumerate() { + if !is_valid { + possible.push(false); + continue; + } + let filter = filters.bytes_at(idx); + vortex_ensure!( + filter.len() == options.bloom.bytes().get(), + "stored n-gram bloom byte length does not match options" + ); + set_bits += filter + .as_slice() + .iter() + .map(|byte| u64::from(byte.count_ones())) + .sum::(); + valid_zones += 1; + possible.push(grams.iter().all(|gram| { + bloom_contains_bytes(filter.as_slice(), gram, options.bloom.hashes().get()) + })); + } + + if diagnostics_enabled() && valid_zones > 0 { + let total_bits = valid_zones as f64 * options.bloom.bytes().get() as f64 * 8.0; + let possible_zones = possible.iter().filter(|value| **value).count(); + tracing::info!( + target: "vortex_layout::skip_index", + index = "ngram_bloom", + pattern = pattern.as_str(), + gram_size = options.gram_size.get(), + grams = grams.len(), + zones = valid_zones, + definitely_absent_zones = valid_zones - possible_zones, + average_fill_ratio = set_bits as f64 / total_bits, + "experimental skip-index probe" + ); + } + + Ok(BoolArray::new(BitBuffer::from_iter(possible), validity).into_array()) + } + + fn is_null_sensitive(&self, _options: &Self::Options) -> bool { + false + } + + fn is_fallible(&self, _options: &Self::Options) -> bool { + true + } +} + +#[derive(Clone, Debug)] +struct NGramLikeRewrite { + options: NGramBloomOptions, +} + +impl StatsRewriteRule for NGramLikeRewrite { + fn scalar_fn_id(&self) -> ScalarFnId { + Like.id() + } + + fn falsify( + &self, + expr: &Expression, + ctx: &StatsRewriteCtx<'_>, + ) -> VortexResult> { + let like = expr.as_::(); + if like.negated || like.case_insensitive || !is_root(expr.child(0)) { + return Ok(None); + } + let Some(pattern) = expr.child(1).as_opt::() else { + return Ok(None); + }; + let Some(pattern_value) = pattern.as_utf8().value() else { + return Ok(None); + }; + if !matches!(ctx.return_dtype(expr.child(0))?, DType::Utf8(_)) + || required_grams(pattern_value, usize::from(self.options.gram_size.get())).is_empty() + { + return Ok(None); + } + + let filter = stat( + expr.child(0).clone(), + NGramBloomFilter.bind(self.options.clone()), + ); + let contains = + NGramBloomContains.new_expr(self.options.clone(), [filter, expr.child(1).clone()]); + Ok(Some(not(contains))) + } +} + +fn insert_grams(bits: &mut [u8], value: &[u8], gram_size: usize, hashes: u8) { + for gram in value.windows(gram_size) { + bloom_insert_bytes(bits, gram, hashes); + } +} + +fn required_grams(pattern: &str, gram_size: usize) -> Vec> { + literal_runs(pattern) + .into_iter() + .flat_map(|run| { + run.windows(gram_size) + .map(<[u8]>::to_vec) + .collect::>() + }) + .collect() +} + +fn literal_runs(pattern: &str) -> Vec> { + let mut runs = Vec::new(); + let mut current = Vec::new(); + let mut chars = pattern.chars(); + while let Some(character) = chars.next() { + match character { + '\\' => { + if let Some(escaped) = chars.next() { + let mut bytes = [0; 4]; + current.extend_from_slice(escaped.encode_utf8(&mut bytes).as_bytes()); + } else { + current.push(b'\\'); + } + } + '%' | '_' => { + if !current.is_empty() { + runs.push(std::mem::take(&mut current)); + } + } + character => { + let mut bytes = [0; 4]; + current.extend_from_slice(character.encode_utf8(&mut bytes).as_bytes()); + } + } + } + if !current.is_empty() { + runs.push(current); + } + runs +} + +fn diagnostics_enabled() -> bool { + std::env::var("VORTEX_EXPERIMENTAL_SKIP_INDEX_DIAGNOSTICS").is_ok_and(|value| value == "1") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extracts_only_required_literal_grams() { + assert_eq!( + required_grams(r"%foo_bar\%baz%", 3), + [ + b"foo".to_vec(), + b"bar".to_vec(), + b"ar%".to_vec(), + b"r%b".to_vec(), + b"%ba".to_vec(), + b"baz".to_vec() + ] + ); + assert!(required_grams("%a%b%", 3).is_empty()); + } + + #[test] + fn gram_membership_has_no_false_negatives() { + let options = NGramBloomOptions::default(); + let mut bits = vec![0; options.bloom.bytes().get()]; + insert_grams( + &mut bits, + b"the quick brown fox", + usize::from(options.gram_size.get()), + options.bloom.hashes().get(), + ); + for gram in b"quick".windows(3) { + assert!(bloom_contains_bytes( + &bits, + gram, + options.bloom.hashes().get() + )); + } + } +} diff --git a/vortex-layout/src/layouts/zoned/mod.rs b/vortex-layout/src/layouts/zoned/mod.rs index 3a4d80902f3..db100d5aaf0 100644 --- a/vortex-layout/src/layouts/zoned/mod.rs +++ b/vortex-layout/src/layouts/zoned/mod.rs @@ -12,6 +12,7 @@ // SPDX-FileCopyrightText: Copyright the Vortex contributors mod builder; +pub mod experimental; mod pruning; mod reader; mod schema; diff --git a/vortex-layout/src/layouts/zoned/writer.rs b/vortex-layout/src/layouts/zoned/writer.rs index 048905dade6..42909179bf2 100644 --- a/vortex-layout/src/layouts/zoned/writer.rs +++ b/vortex-layout/src/layouts/zoned/writer.rs @@ -52,6 +52,7 @@ use crate::sequence::SequentialStreamExt; /// /// The input stream is assumed to already be partitioned into one chunk per zone, except /// possibly the final partial zone. +#[derive(Clone)] pub struct ZonedLayoutOptions { /// The size of a statistics block pub block_size: NonZeroUsize, @@ -196,7 +197,10 @@ impl LayoutStrategy for ZonedStrategy { } } -fn default_zoned_aggregate_fns(dtype: &DType, session: &VortexSession) -> Arc<[AggregateFnRef]> { +pub(super) fn default_zoned_aggregate_fns( + dtype: &DType, + session: &VortexSession, +) -> Arc<[AggregateFnRef]> { let (max, min) = match dtype { DType::Utf8(_) | DType::Binary(_) => ( BoundedMax.bind(BoundedMaxOptions { From eb47c0aa43fff364155c8b29b458ccce936be23d Mon Sep 17 00:00:00 2001 From: Connor Tsui Date: Wed, 22 Jul 2026 15:10:06 -0400 Subject: [PATCH 03/13] Focus skipping-index SQL benchmark matrix Signed-off-by: Connor Tsui --- .github/workflows/sql-benchmarks.yml | 9 ------- .github/workflows/sql-pr.yml | 39 +++++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/.github/workflows/sql-benchmarks.yml b/.github/workflows/sql-benchmarks.yml index 2356b87a5c0..425feef9263 100644 --- a/.github/workflows/sql-benchmarks.yml +++ b/.github/workflows/sql-benchmarks.yml @@ -544,15 +544,6 @@ jobs: fail-fast: false matrix: include: ${{ fromJSON(inputs.benchmark_profile == 'base' && inputs.base_benchmark_matrix || inputs.benchmark_matrix) }} - exclude: - - id: appian-nvme - - id: polarsignals - - id: statpopgen - - id: tpcds-nvme - - id: tpch-nvme - - id: tpch-nvme-10 - - id: tpch-s3 - - id: tpch-s3-10 runs-on: >- ${{ github.repository == 'vortex-data/vortex' diff --git a/.github/workflows/sql-pr.yml b/.github/workflows/sql-pr.yml index 45b0ed1d675..42118f25847 100644 --- a/.github/workflows/sql-pr.yml +++ b/.github/workflows/sql-pr.yml @@ -39,4 +39,41 @@ jobs: secrets: inherit with: mode: "pr" - benchmark_profile: ${{ inputs.benchmark_profile || 'base' }} + benchmark_profile: "full" + benchmark_matrix: | + [ + { + "id": "clickbench-nvme", + "subcommand": "clickbench", + "name": "Clickbench on NVME", + "data_formats": ["parquet", "vortex"], + "pr_targets": [{"engine": "datafusion", "format": "vortex"}], + "develop_targets": [{"engine": "datafusion", "format": "vortex"}] + }, + { + "id": "clickbench-sorted-nvme", + "subcommand": "clickbench-sorted", + "name": "Clickbench Sorted on NVME", + "data_formats": ["parquet", "vortex"], + "pr_targets": [{"engine": "datafusion", "format": "vortex"}], + "develop_targets": [{"engine": "datafusion", "format": "vortex"}] + }, + { + "id": "fineweb", + "subcommand": "fineweb", + "name": "FineWeb NVMe", + "data_formats": ["parquet", "vortex"], + "pr_targets": [{"engine": "datafusion", "format": "vortex"}], + "develop_targets": [{"engine": "datafusion", "format": "vortex"}] + }, + { + "id": "fineweb-s3", + "subcommand": "fineweb", + "name": "FineWeb S3", + "local_dir": "vortex-bench/data/fineweb", + "remote_storage": "s3://vortex-ci-benchmark-datasets/${{github.ref_name}}/${{github.run_id}}/fineweb/", + "data_formats": ["parquet", "vortex"], + "pr_targets": [{"engine": "datafusion", "format": "vortex"}], + "develop_targets": [{"engine": "datafusion", "format": "vortex"}] + } + ] From 9a3132228f26f76720a63a108db426fc53ae8449 Mon Sep 17 00:00:00 2001 From: Connor Tsui Date: Wed, 22 Jul 2026 15:15:52 -0400 Subject: [PATCH 04/13] Fix spike lint checks Signed-off-by: Connor Tsui --- SKIPPING_INDEX_SPIKE.md | 3 +++ scripts/capture-file-sizes.py | 6 +----- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/SKIPPING_INDEX_SPIKE.md b/SKIPPING_INDEX_SPIKE.md index 7dac4e6f635..c61806ebf01 100644 --- a/SKIPPING_INDEX_SPIKE.md +++ b/SKIPPING_INDEX_SPIKE.md @@ -1,3 +1,6 @@ + + + # Experimental skipping-index findings This spike answers the core questions in [#8901](https://github.com/vortex-data/vortex/issues/8901): a third-party-style aggregate can be persisted in a `ZonedLayout`, rebound on a fresh reader, and used by an equality rewrite to skip zones without changing scan planning. diff --git a/scripts/capture-file-sizes.py b/scripts/capture-file-sizes.py index f5aa84b2558..66bc9b5925c 100644 --- a/scripts/capture-file-sizes.py +++ b/scripts/capture-file-sizes.py @@ -61,11 +61,7 @@ def main(): directory_name = format_dir.name if directory_name not in formats_to_capture: continue - format_name = ( - "vortex-file-compressed" - if directory_name == experimental_vortex_dir - else directory_name - ) + format_name = "vortex-file-compressed" if directory_name == experimental_vortex_dir else directory_name # Extract scale factor from path (e.g., "1.0" for tpch/1.0/vortex-file-compressed) # Default to "1.0" if no intermediate directory (e.g., clickbench) From 0eb7fc573af706cbeb0775475e8aa6b1d11c56c3 Mon Sep 17 00:00:00 2001 From: Connor Tsui Date: Wed, 22 Jul 2026 15:19:16 -0400 Subject: [PATCH 05/13] Support benchmark comments from manual dispatch Signed-off-by: Connor Tsui --- .github/workflows/sql-benchmarks.yml | 6 ++++++ .github/workflows/sql-pr.yml | 10 ++++++++++ 2 files changed, 16 insertions(+) diff --git a/.github/workflows/sql-benchmarks.yml b/.github/workflows/sql-benchmarks.yml index 425feef9263..9cfdf40e822 100644 --- a/.github/workflows/sql-benchmarks.yml +++ b/.github/workflows/sql-benchmarks.yml @@ -10,6 +10,10 @@ on: required: false type: string default: i7i.metal-24xl + pr_number: + required: false + type: string + default: "" benchmark_matrix: required: false type: string @@ -712,6 +716,7 @@ jobs: uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b # v3 with: file-path: comment.md + pr-number: ${{ inputs.pr_number || github.event.pull_request.number }} # There is exactly one comment per comment-tag. If a comment with this tag already exists, # this action will *update* the comment instead of posting a new comment. comment-tag: bench-pr-comment-${{ matrix.id }} @@ -724,6 +729,7 @@ jobs: # 🚨🚨🚨❌❌❌ SQL BENCHMARK FAILED ❌❌❌🚨🚨🚨 Benchmark `${{ matrix.name }}` (${{ inputs.benchmark_profile }}) failed! Check the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details. + pr-number: ${{ inputs.pr_number || github.event.pull_request.number }} comment-tag: bench-pr-comment-${{ matrix.id }} - name: Upload Benchmark Results diff --git a/.github/workflows/sql-pr.yml b/.github/workflows/sql-pr.yml index 42118f25847..fa0dd2d13c1 100644 --- a/.github/workflows/sql-pr.yml +++ b/.github/workflows/sql-pr.yml @@ -17,6 +17,10 @@ on: required: false type: string default: "base" + pr_number: + required: false + type: string + default: "" workflow_dispatch: inputs: benchmark_profile: @@ -27,6 +31,11 @@ on: options: - "base" - "full" + pr_number: + description: "PR number for benchmark result comments" + required: false + type: string + default: "" permissions: contents: read @@ -39,6 +48,7 @@ jobs: secrets: inherit with: mode: "pr" + pr_number: ${{ inputs.pr_number }} benchmark_profile: "full" benchmark_matrix: | [ From e76c7e4f5ff21bfafdbfe7e5ef6e0e1d064a6e11 Mon Sep 17 00:00:00 2001 From: Connor Tsui Date: Wed, 22 Jul 2026 15:21:11 -0400 Subject: [PATCH 06/13] Report Bloom pruning diagnostics Signed-off-by: Connor Tsui --- .../src/layouts/zoned/experimental.rs | 46 +++++++++++++++---- .../src/layouts/zoned/experimental/ngram.rs | 5 +- 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/vortex-layout/src/layouts/zoned/experimental.rs b/vortex-layout/src/layouts/zoned/experimental.rs index d60062b7382..2997e054aaf 100644 --- a/vortex-layout/src/layouts/zoned/experimental.rs +++ b/vortex-layout/src/layouts/zoned/experimental.rs @@ -372,23 +372,45 @@ impl ScalarFnVTable for BloomContains { let validity = filters.varbinview_validity(); let valid = validity.execute_mask(filters.len(), ctx)?; + let mut possible = Vec::with_capacity(filters.len()); + let mut set_bits = 0u64; + let mut valid_zones = 0usize; for (idx, is_valid) in valid.iter().enumerate() { if is_valid { + let filter = filters.bytes_at(idx); vortex_ensure!( - filters.bytes_at(idx).len() == options.bytes.get(), + filter.len() == options.bytes.get(), "stored bloom byte length does not match options" ); - } - } - let bits = BitBuffer::from_iter(valid.iter().enumerate().map(|(idx, is_valid)| { - is_valid - && bloom_contains( - filters.bytes_at(idx).as_slice(), + set_bits += filter + .as_slice() + .iter() + .map(|byte| u64::from(byte.count_ones())) + .sum::(); + valid_zones += 1; + possible.push(bloom_contains( + filter.as_slice(), needle, options.hashes.get(), - ) - })); - Ok(BoolArray::new(bits, validity).into_array()) + )); + } else { + possible.push(false); + } + } + if diagnostics_enabled() && valid_zones > 0 { + let total_bits = valid_zones as f64 * options.bytes.get() as f64 * 8.0; + let possible_zones = possible.iter().filter(|value| **value).count(); + tracing::info!( + target: "vortex_layout::skip_index", + index = "bloom", + needle, + zones = valid_zones, + definitely_absent_zones = valid_zones - possible_zones, + average_fill_ratio = set_bits as f64 / total_bits, + "experimental skip-index probe" + ); + } + Ok(BoolArray::new(BitBuffer::from_iter(possible), validity).into_array()) } fn is_null_sensitive(&self, _options: &Self::Options) -> bool { @@ -513,6 +535,10 @@ fn splitmix64(mut value: u64) -> u64 { value ^ (value >> 31) } +pub(super) fn diagnostics_enabled() -> bool { + std::env::var("VORTEX_EXPERIMENTAL_SKIP_INDEX_DIAGNOSTICS").is_ok_and(|value| value == "1") +} + #[cfg(test)] mod tests { use std::sync::Arc; diff --git a/vortex-layout/src/layouts/zoned/experimental/ngram.rs b/vortex-layout/src/layouts/zoned/experimental/ngram.rs index 1ab57660e2c..7802beb8f8f 100644 --- a/vortex-layout/src/layouts/zoned/experimental/ngram.rs +++ b/vortex-layout/src/layouts/zoned/experimental/ngram.rs @@ -50,6 +50,7 @@ use super::BloomPartial; use super::SkipIndex; use super::bloom_contains_bytes; use super::bloom_insert_bytes; +use super::diagnostics_enabled; /// Persisted tuning for a bloom filter populated with every byte n-gram in a UTF-8 zone. #[derive(Clone, Debug, PartialEq, Eq, Hash)] @@ -476,10 +477,6 @@ fn literal_runs(pattern: &str) -> Vec> { runs } -fn diagnostics_enabled() -> bool { - std::env::var("VORTEX_EXPERIMENTAL_SKIP_INDEX_DIAGNOSTICS").is_ok_and(|value| value == "1") -} - #[cfg(test)] mod tests { use super::*; From 811792f968df9b8f90aebcc15018d6ec20fd7340 Mon Sep 17 00:00:00 2001 From: Connor Tsui Date: Wed, 22 Jul 2026 15:34:36 -0400 Subject: [PATCH 07/13] Add same-run SQL benchmark controls Signed-off-by: Connor Tsui --- .github/workflows/sql-benchmarks.yml | 2 +- .github/workflows/sql-pr.yml | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/sql-benchmarks.yml b/.github/workflows/sql-benchmarks.yml index 9cfdf40e822..44ef55c7e6a 100644 --- a/.github/workflows/sql-benchmarks.yml +++ b/.github/workflows/sql-benchmarks.yml @@ -589,7 +589,7 @@ jobs: shell: bash run: | if [ "${{ inputs.mode }}" = "pr" ]; then - targets='[{"engine":"datafusion","format":"vortex"}]' + targets='${{ toJSON(matrix.pr_targets) }}' else targets='${{ toJSON(matrix.develop_targets) }}' fi diff --git a/.github/workflows/sql-pr.yml b/.github/workflows/sql-pr.yml index fa0dd2d13c1..2c768838404 100644 --- a/.github/workflows/sql-pr.yml +++ b/.github/workflows/sql-pr.yml @@ -57,24 +57,24 @@ jobs: "subcommand": "clickbench", "name": "Clickbench on NVME", "data_formats": ["parquet", "vortex"], - "pr_targets": [{"engine": "datafusion", "format": "vortex"}], - "develop_targets": [{"engine": "datafusion", "format": "vortex"}] + "pr_targets": [{"engine": "datafusion", "format": "parquet"}, {"engine": "datafusion", "format": "vortex"}], + "develop_targets": [{"engine": "datafusion", "format": "parquet"}, {"engine": "datafusion", "format": "vortex"}] }, { "id": "clickbench-sorted-nvme", "subcommand": "clickbench-sorted", "name": "Clickbench Sorted on NVME", "data_formats": ["parquet", "vortex"], - "pr_targets": [{"engine": "datafusion", "format": "vortex"}], - "develop_targets": [{"engine": "datafusion", "format": "vortex"}] + "pr_targets": [{"engine": "datafusion", "format": "parquet"}, {"engine": "datafusion", "format": "vortex"}], + "develop_targets": [{"engine": "datafusion", "format": "parquet"}, {"engine": "datafusion", "format": "vortex"}] }, { "id": "fineweb", "subcommand": "fineweb", "name": "FineWeb NVMe", "data_formats": ["parquet", "vortex"], - "pr_targets": [{"engine": "datafusion", "format": "vortex"}], - "develop_targets": [{"engine": "datafusion", "format": "vortex"}] + "pr_targets": [{"engine": "datafusion", "format": "parquet"}, {"engine": "datafusion", "format": "vortex"}], + "develop_targets": [{"engine": "datafusion", "format": "parquet"}, {"engine": "datafusion", "format": "vortex"}] }, { "id": "fineweb-s3", @@ -83,7 +83,7 @@ jobs: "local_dir": "vortex-bench/data/fineweb", "remote_storage": "s3://vortex-ci-benchmark-datasets/${{github.ref_name}}/${{github.run_id}}/fineweb/", "data_formats": ["parquet", "vortex"], - "pr_targets": [{"engine": "datafusion", "format": "vortex"}], - "develop_targets": [{"engine": "datafusion", "format": "vortex"}] + "pr_targets": [{"engine": "datafusion", "format": "parquet"}, {"engine": "datafusion", "format": "vortex"}], + "develop_targets": [{"engine": "datafusion", "format": "parquet"}, {"engine": "datafusion", "format": "vortex"}] } ] From 5432d0a54a77dcabcc6694e0f79fd4f3b8337d4c Mon Sep 17 00:00:00 2001 From: Connor Tsui Date: Wed, 22 Jul 2026 15:47:12 -0400 Subject: [PATCH 08/13] Scope skipping-index benchmark environment Signed-off-by: Connor Tsui --- .github/workflows/sql-benchmarks.yml | 10 +++++++--- .github/workflows/sql-pr.yml | 1 + 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/sql-benchmarks.yml b/.github/workflows/sql-benchmarks.yml index 44ef55c7e6a..d971e8ad89a 100644 --- a/.github/workflows/sql-benchmarks.yml +++ b/.github/workflows/sql-benchmarks.yml @@ -14,6 +14,10 @@ on: required: false type: string default: "" + experimental_skip_index: + required: false + type: boolean + default: false benchmark_matrix: required: false type: string @@ -535,9 +539,9 @@ jobs: timeout-minutes: 120 env: VORTEX_EXPERIMENTAL_PATCHED_ARRAY: "1" - VORTEX_EXPERIMENTAL_SKIP_INDEX: "1" - VORTEX_EXPERIMENTAL_SKIP_INDEX_DIAGNOSTICS: "1" - VORTEX_EXPERIMENTAL_SKIP_INDEX_DIR: "vortex-file-skipping" + VORTEX_EXPERIMENTAL_SKIP_INDEX: ${{ inputs.experimental_skip_index && '1' || '' }} + VORTEX_EXPERIMENTAL_SKIP_INDEX_DIAGNOSTICS: ${{ inputs.experimental_skip_index && '1' || '' }} + VORTEX_EXPERIMENTAL_SKIP_INDEX_DIR: ${{ inputs.experimental_skip_index && 'vortex-file-skipping' || '' }} VORTEX_EXPERIMENTAL_NGRAM_BYTES: "16384" VORTEX_EXPERIMENTAL_NGRAM_HASHES: "5" VORTEX_EXPERIMENTAL_NGRAM_ZONE_LEN: ${{ startsWith(matrix.id, 'fineweb') && '1024' || '8192' }} diff --git a/.github/workflows/sql-pr.yml b/.github/workflows/sql-pr.yml index 2c768838404..395893523d9 100644 --- a/.github/workflows/sql-pr.yml +++ b/.github/workflows/sql-pr.yml @@ -50,6 +50,7 @@ jobs: mode: "pr" pr_number: ${{ inputs.pr_number }} benchmark_profile: "full" + experimental_skip_index: true benchmark_matrix: | [ { From 0b73d9b20b0376853cc9c00f45f318cbb6d18e4f Mon Sep 17 00:00:00 2001 From: Connor Tsui Date: Wed, 22 Jul 2026 15:47:14 -0400 Subject: [PATCH 09/13] Record skipping-index benchmark findings Signed-off-by: Connor Tsui --- SKIPPING_INDEX_SPIKE.md | 66 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 3 deletions(-) diff --git a/SKIPPING_INDEX_SPIKE.md b/SKIPPING_INDEX_SPIKE.md index c61806ebf01..725e3a2628c 100644 --- a/SKIPPING_INDEX_SPIKE.md +++ b/SKIPPING_INDEX_SPIKE.md @@ -67,8 +67,61 @@ its dictionary, compressor, coalescing, or flat-layout stages: The benchmark artifact is written to a separate `vortex-file-skipping` directory so cached normal Vortex files cannot contaminate the comparison. The focused draft-PR run covers ClickBench, -ClickBench sorted, FineWeb NVMe, and FineWeb S3 with DataFusion/Vortex. Results will be recorded here -after the dedicated benchmark run completes. +ClickBench sorted, FineWeb NVMe, and FineWeb S3. Each candidate job times unchanged +DataFusion/Parquet alongside indexed DataFusion/Vortex, and the reporting harness subtracts the +Parquet control shift from the attributed Vortex impact. The controlled run is +[29951570194](https://github.com/vortex-data/vortex/actions/runs/29951570194). + +| Suite | Vortex PR/base | Parquet control | Attributed Vortex impact | Indexed Vortex size | Verdict | +| --- | ---: | ---: | ---: | ---: | --- | +| ClickBench NVMe | 1.004x | 1.014x | -1.0% | +4.3% | No suite-wide signal | +| ClickBench sorted NVMe | 0.944x | 0.991x | -4.8% | +2.9% | No clear signal; noisy | +| FineWeb NVMe | 1.125x | 1.039x | +8.3% | +3.3% | No clear suite-wide signal; indexed queries regress | +| FineWeb S3 | 1.057x | 0.984x | +7.5% | not captured | No clear signal; environment noisy | + +The equality result does materialize in a real benchmark. ClickBench q19 is the indexed +`UserID = 435090932899640449` point lookup. It improves from 29.23 ms to 21.42 ms, a raw 27% +reduction. The matching unchanged Parquet query moves from 28.17 ms to 27.45 ms, so the +control-adjusted Vortex ratio is approximately `0.73 / 0.97 = 0.75`: **about 25% faster, or +1.33x**. The diagnostic mask explains the timing: one scan examines 12,299 zones across 100 files, +proves 12,294 absent, and keeps only five. The entire 43-query ClickBench suite remains neutral +because the other queries cannot use this point-lookup index. The +4.3% file-size cost includes the +`UserID` bloom and two larger trigram indexes, so it is not the isolated equality-index overhead. +The full tables are in the +[ClickBench benchmark comment](https://github.com/vortex-data/vortex/pull/8907#issuecomment-5050548603). + +The trigram result is negative. FineWeb's one 1,023-zone table produced these masks: + +| Predicate | Indexed column | Zones pruned | Average fill | +| --- | --- | ---: | ---: | +| `url LIKE '%google%'` | `url` | 21/1,023 | 35.4% | +| `text LIKE '%Google%'` | `text` | 0/1,023 | 79.9% | +| `text LIKE '% vortex %'` | `text` | 0/1,023 | 79.9% | +| `url LIKE '%espn%'` | `url` | 328/1,023 | 35.4% | +| `url LIKE '%www.espn.go.com%'` | `url` | 625/1,023 | 35.4% | +| `url LIKE '%espn.go.com%'` | `url` | 607/1,023 | 35.4% | +| `file_path LIKE '%/CC-MAIN-2014-%'` | `file_path` | 0/1,023 | 5.6% | + +Despite real pruning, every trigram-targeted FineWeb query is slower after its matching Parquet +control adjustment. On NVMe the adjusted costs are approximately +21% for q3, +3% for q5, +10% +for q6, +25% for q7, and +36% for q8. On S3 they are approximately +13%, +28%, +15%, +19%, and ++27%. The 16 KiB text filter is nearly saturated, while the low-fill `file_path` case shows a +different weakness: all of the common three-byte grams occur somewhere in every zone, even though +the complete string does not. Reading and probing the index then adds cost without avoiding enough +data. See the [FineWeb NVMe](https://github.com/vortex-data/vortex/pull/8907#issuecomment-5050536937) +and [FineWeb S3](https://github.com/vortex-data/vortex/pull/8907#issuecomment-5050543034) reports. + +ClickBench q20-q23 and sorted q23 contain the expected `URL`/`Title` substring predicates, but the +trigram probe executes zero times in both job logs. Their expression arrives in a shape not matched +by this rewrite, so any timing movement is not evidence for the index. In particular, sorted q23's +raw 20% improvement cannot be credited to trigram pruning. The +[sorted report](https://github.com/vortex-data/vortex/pull/8907#issuecomment-5050576221) is retained +as a useful expression-pushdown gap to investigate. + +The SQL harness reports wall time, file size, and the added zone-mask diagnostics, but not +per-query segment requests or payload bytes. Those I/O counters therefore come from the controlled +synthetic benchmark above; the real-suite claim is intentionally limited to wall time, file size, +and zones pruned. ### Off-the-shelf pieces @@ -118,4 +171,11 @@ data pipeline instead of requiring callers to reconstruct it. - The hash algorithm, format version, supported dtypes, null semantics, sizing policy, and saturation behavior need an explicit compatibility contract. This spike supports `i64` equality and byte-oriented UTF-8 trigrams only. - Bloom stats for all zones are regular child-array data. Their layout and caching should be profiled for many indexed columns and remote reads. -Recommendation: pursue the bundle interface and an explicit per-column writer declaration, while retaining the three lower-level registries. The selective equality benchmark is large enough to justify a production-quality experiment, but index selection must remain workload-aware and opt-in. The SQL run must show that real substring selectivity survives filter saturation and pays back the added file bytes before the trigram prototype is considered a win. +Recommendation: pursue the bundle interface and an explicit per-column writer declaration while +retaining the three lower-level registries. A production-quality equality Bloom experiment is +justified for opt-in, high-cardinality point-lookups: it is correct, prunes as expected, and speeds +up ClickBench q19 after a same-run control. Do not pursue this generic fixed-size trigram Bloom as +the production design. First fix rewrite coverage for the actual ClickBench expression shape, then +prototype adaptive sizing/saturation cutoffs and a rarer-token or sequence-aware representation +that can reject zones when common individual trigrams cannot. Any successor should have to beat the +current negative FineWeb results, including index bytes and probe cost, before being enabled. From 5972e02edfd85579ab569d5be0e7ffa45839f114 Mon Sep 17 00:00:00 2001 From: Connor Tsui Date: Thu, 23 Jul 2026 10:39:22 -0400 Subject: [PATCH 10/13] Narrow skipping indexes to Bloom equality Signed-off-by: Connor Tsui --- .github/workflows/sql-benchmarks.yml | 11 +- .github/workflows/sql-pr.yml | 28 +- BLOOM_SKIP_INDEX.md | 136 +++++ SKIPPING_INDEX_SPIKE.md | 181 ------ scripts/capture-file-sizes.py | 8 +- vortex-bench/src/conversions.rs | 34 +- vortex-bench/src/lib.rs | 69 +-- vortex-file/src/strategy.rs | 4 +- ...erimental_bloom.rs => bloom_skip_index.rs} | 157 +----- .../src/layouts/zoned/experimental/ngram.rs | 518 ------------------ vortex-layout/src/layouts/zoned/mod.rs | 2 +- .../zoned/{experimental.rs => skip_index.rs} | 56 +- 12 files changed, 198 insertions(+), 1006 deletions(-) create mode 100644 BLOOM_SKIP_INDEX.md delete mode 100644 SKIPPING_INDEX_SPIKE.md rename vortex-file/tests/{experimental_bloom.rs => bloom_skip_index.rs} (76%) delete mode 100644 vortex-layout/src/layouts/zoned/experimental/ngram.rs rename vortex-layout/src/layouts/zoned/{experimental.rs => skip_index.rs} (91%) diff --git a/.github/workflows/sql-benchmarks.yml b/.github/workflows/sql-benchmarks.yml index d971e8ad89a..9bdca960aa7 100644 --- a/.github/workflows/sql-benchmarks.yml +++ b/.github/workflows/sql-benchmarks.yml @@ -14,7 +14,7 @@ on: required: false type: string default: "" - experimental_skip_index: + bloom_skip_index: required: false type: boolean default: false @@ -539,12 +539,9 @@ jobs: timeout-minutes: 120 env: VORTEX_EXPERIMENTAL_PATCHED_ARRAY: "1" - VORTEX_EXPERIMENTAL_SKIP_INDEX: ${{ inputs.experimental_skip_index && '1' || '' }} - VORTEX_EXPERIMENTAL_SKIP_INDEX_DIAGNOSTICS: ${{ inputs.experimental_skip_index && '1' || '' }} - VORTEX_EXPERIMENTAL_SKIP_INDEX_DIR: ${{ inputs.experimental_skip_index && 'vortex-file-skipping' || '' }} - VORTEX_EXPERIMENTAL_NGRAM_BYTES: "16384" - VORTEX_EXPERIMENTAL_NGRAM_HASHES: "5" - VORTEX_EXPERIMENTAL_NGRAM_ZONE_LEN: ${{ startsWith(matrix.id, 'fineweb') && '1024' || '8192' }} + VORTEX_BLOOM_SKIP_INDEX: ${{ inputs.bloom_skip_index && '1' || '' }} + VORTEX_SKIP_INDEX_DIAGNOSTICS: ${{ inputs.bloom_skip_index && '1' || '' }} + VORTEX_BLOOM_SKIP_INDEX_DIR: ${{ inputs.bloom_skip_index && 'vortex-file-bloom' || '' }} FLAT_LAYOUT_INLINE_ARRAY_NODE: "1" # Makes python output nicer COLUMNS: 120 diff --git a/.github/workflows/sql-pr.yml b/.github/workflows/sql-pr.yml index 395893523d9..e720cb9e97b 100644 --- a/.github/workflows/sql-pr.yml +++ b/.github/workflows/sql-pr.yml @@ -50,7 +50,7 @@ jobs: mode: "pr" pr_number: ${{ inputs.pr_number }} benchmark_profile: "full" - experimental_skip_index: true + bloom_skip_index: true benchmark_matrix: | [ { @@ -60,31 +60,5 @@ jobs: "data_formats": ["parquet", "vortex"], "pr_targets": [{"engine": "datafusion", "format": "parquet"}, {"engine": "datafusion", "format": "vortex"}], "develop_targets": [{"engine": "datafusion", "format": "parquet"}, {"engine": "datafusion", "format": "vortex"}] - }, - { - "id": "clickbench-sorted-nvme", - "subcommand": "clickbench-sorted", - "name": "Clickbench Sorted on NVME", - "data_formats": ["parquet", "vortex"], - "pr_targets": [{"engine": "datafusion", "format": "parquet"}, {"engine": "datafusion", "format": "vortex"}], - "develop_targets": [{"engine": "datafusion", "format": "parquet"}, {"engine": "datafusion", "format": "vortex"}] - }, - { - "id": "fineweb", - "subcommand": "fineweb", - "name": "FineWeb NVMe", - "data_formats": ["parquet", "vortex"], - "pr_targets": [{"engine": "datafusion", "format": "parquet"}, {"engine": "datafusion", "format": "vortex"}], - "develop_targets": [{"engine": "datafusion", "format": "parquet"}, {"engine": "datafusion", "format": "vortex"}] - }, - { - "id": "fineweb-s3", - "subcommand": "fineweb", - "name": "FineWeb S3", - "local_dir": "vortex-bench/data/fineweb", - "remote_storage": "s3://vortex-ci-benchmark-datasets/${{github.ref_name}}/${{github.run_id}}/fineweb/", - "data_formats": ["parquet", "vortex"], - "pr_targets": [{"engine": "datafusion", "format": "parquet"}, {"engine": "datafusion", "format": "vortex"}], - "develop_targets": [{"engine": "datafusion", "format": "parquet"}, {"engine": "datafusion", "format": "vortex"}] } ] diff --git a/BLOOM_SKIP_INDEX.md b/BLOOM_SKIP_INDEX.md new file mode 100644 index 00000000000..c754f73ad3e --- /dev/null +++ b/BLOOM_SKIP_INDEX.md @@ -0,0 +1,136 @@ + + + +# Bloom skipping-index findings + +This implementation answers the core questions in +[#8901](https://github.com/vortex-data/vortex/issues/8901): a custom aggregate can be persisted in a +`ZonedLayout`, rebound on a fresh reader, and used by an equality rewrite to skip zones without +changing scan planning. + +Substring search is intentionally out of scope. `LIKE '%substr%'` needs a separate inverted-index +design rather than overloading the per-zone Bloom interface. + +## Result + +The Bloom skipping index consists of: + +- a serializable per-zone aggregate stored as `Binary`, +- a scalar membership probe, +- an equality `StatsRewriteRule` that emits + `not(bloom_contains(stat(root(), bloom), literal))`, and +- a `SkipIndex` bundle plus `ZonedLayoutOptions::with_skip_index` for explicit write-side + declaration. + +The roundtrip test writes four zones through the normal `WriteStrategyBuilder` pipeline, opens the +file with a fresh registered session, and checks both hits and misses. The hit keeps exactly one +zone. The absent value is deliberately inside every zone's min/max range, but the Bloom proof +skips all four zones. Indexed results are array-equal to a full scan through a fresh session that +does not know about the custom index. + +Unknown readers remain safe when `allow_unknown()` is enabled: they ignore the unknown aggregate +and read the data child without pruning. #8904 provides unknown-aggregate ignorability and is now +on `develop`. #8905 is still open as of 2026-07-23, so its empty-zone-map reader bypass remains on +this branch as a prerequisite. + +## Focused benchmark + +Command: + +```text +cargo test --release -p vortex-file --test bloom_skip_index bloom_point_lookup_benchmark -- --ignored --nocapture +``` + +The benchmark uses 2,097,152 interleaved, high-cardinality `i64` values in 256 zones of 8,192 rows. +Interleaving makes the point lookup fall inside every zone's min/max range, so ordinary range +statistics cannot skip a zone. Timings are medians of nine fresh-reader scans after two warmups. +Segment payload counts are measured below the file reader with a counting `SegmentSource`. + +| Case | Zones pruned | File size | Median | Segment requests | Unique segments | Segment payload | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| No Bloom | 0/256 | 16,845,924 B | 1.541 ms | 258 | 257 | 16,897,520 B | +| Bloom | 251/256 | 18,947,380 B | 0.571 ms | 7 | 6 | 2,504,196 B | + +The Bloom scan was **2.70x faster** and moved **6.75x less segment payload**. Five zones survived: +the one true hit and four false positives. The file grew by 2,101,456 bytes, or 12.5%, from one +8 KiB filter per zone plus layout overhead. + +The negative control uses cardinality 16 and queries a value present in every zone: + +| Case | Zones pruned | File size | Median | Segment requests | Unique segments | Segment payload | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| No Bloom | 0/256 | 16,843,876 B | 1.807 ms | 513 | 257 | 33,651,004 B | +| Bloom | 0/256 | 18,945,332 B | 1.832 ms | 513 | 257 | 35,752,396 B | + +With nothing to prune, the indexed scan was 1.4% slower and moved 6.2% more segment payload. A +Bloom index only makes sense when avoided data reads pay for loading and probing the zone table. + +## Existing SQL benchmark + +The benchmark writer adds a Bloom index only to ClickBench `UserID`, using 8,192-row zones. It +retains the normal dictionary, compressor, coalescing, and flat-layout stages and writes the +candidate to a separate `vortex-file-bloom` directory. The candidate job times unchanged +DataFusion/Parquet alongside indexed DataFusion/Vortex so runner-wide movement is visible. + +The earlier mixed-index run already isolated the point-lookup behavior: ClickBench q19 improved +from 29.23 ms to 21.42 ms, approximately **1.33x faster after its Parquet control**. One scan proved +12,294 of 12,299 zones absent and kept only five. The full 43-query suite stayed neutral because the +other queries could not use the equality index. + +A Bloom-only controlled rerun is required to replace the mixed-index file-size number and confirm +the cleaned implementation's wall time. + +## Interface recommendation + +Keep the aggregate, scalar-function, and rewrite registries composable, but expose one bundle at +the registration and write-declaration boundary: + +```rust +pub trait SkipIndex: Debug + Send + Sync + 'static { + fn aggregate_fn(&self, input_dtype: &DType) -> Option; + fn register(&self, session: &VortexSession); +} + +impl ZonedLayoutOptions { + pub fn with_skip_index( + self, + index: &I, + input_dtype: &DType, + session: &VortexSession, + ) -> VortexResult; +} +``` + +This keeps the implementation pieces reusable while giving callers one object to register on +writer and reader sessions and one explicit per-column declaration. The aggregate, probe, and +rewrite types are private behind `BloomSkipIndex`; users cannot accidentally register only part of +the index. + +`WriteStrategyBuilder::with_field_zoned_options` is the per-column seam. It changes only the zoned +statistics for a field and preserves the normal physical data pipeline. + +## Off-the-shelf filter primitive + +Arrow Rust's `parquet::bloom_filter::Sbbf` provides stable split-block Bloom semantics, byte +serialization, membership checks, NDV/FPP sizing, and folding. It does not replace the +Vortex-specific aggregate persistence, stats rewrite, probe expression, or writer declaration. +Using it directly from `vortex-layout` would also add an undesirable Parquet dependency. + +For production, put an SBBF-compatible implementation in a small neutral crate, then use it behind +`BloomSkipIndex`. + +## Remaining work + +- Aggregate identity includes serialized sizing options. Readers must currently register a rewrite + with exactly the writer's byte and hash counts; production discovery should be automatic. +- The hash algorithm, format version, supported dtypes, null semantics, sizing policy, and + saturation behavior need an explicit compatibility contract. This implementation supports + `i64` equality only. +- Unknown aggregate fallback disables the whole zone map rather than retaining known statistics. + That is safe but leaves pruning performance on the table. +- Filter sizing and zone length must remain workload-aware and opt-in. The low-cardinality control + demonstrates the cost when pruning cannot occur. + +Recommendation: continue with an opt-in equality Bloom index and the bundled `SkipIndex` interface. +The correctness proof, synthetic I/O reduction, and ClickBench point lookup justify a +production-quality follow-up. Design substring search separately as an inverted index. diff --git a/SKIPPING_INDEX_SPIKE.md b/SKIPPING_INDEX_SPIKE.md deleted file mode 100644 index 725e3a2628c..00000000000 --- a/SKIPPING_INDEX_SPIKE.md +++ /dev/null @@ -1,181 +0,0 @@ - - - -# Experimental skipping-index findings - -This spike answers the core questions in [#8901](https://github.com/vortex-data/vortex/issues/8901): a third-party-style aggregate can be persisted in a `ZonedLayout`, rebound on a fresh reader, and used by an equality rewrite to skip zones without changing scan planning. - -## Result - -Yes, it works. The prototype adds experimental `i64` equality and UTF-8 trigram-bloom indexes consisting of: - -- a serializable per-zone `BloomFilter` aggregate stored as `Binary`, -- a `bloom_contains` scalar probe, -- an equality `StatsRewriteRule` that emits `not(bloom_contains(stat(root(), bloom), literal))`, and -- a small `SkipIndex` bundle plus `ZonedLayoutOptions::with_skip_index` as the explicit write-side declaration. - -The trigram variant stores every three-byte window from a zone and rewrites case-sensitive -`LIKE` expressions when their pattern contains at least one three-byte literal run. For -`LIKE '%needle%'`, a zone is skipped if any required trigram is definitely absent. SQL `%` and -`_` wildcards and backslash escapes are parsed conservatively; `ILIKE`, negated `LIKE`, and patterns -without a three-byte literal remain inconclusive. - -The roundtrip tests write four zones, open the files with fresh registered sessions, and check both -hits and misses. The equality hit keeps exactly one data zone. Its absent value is deliberately -inside every zone's min/max range, but the bloom proof skips all four zones. The substring hit also -keeps exactly one zone and an absent substring skips all four. Results from every indexed scan are -array-equal to scans through fresh sessions without the custom indexes. - -The unregistered reader case also works when `allow_unknown()` is enabled: it ignores the unknown aggregate and reads the data without pruning. That behavior depends on #8904 and #8905. As of 2026-07-22 both PRs are open and absent from `develop`, so their commits are included on this experimental branch as prerequisites. - -## Benchmark - -Command: - -```text -cargo test --release -p vortex-file --test experimental_bloom bloom_point_lookup_benchmark -- --ignored --nocapture -``` - -The focused benchmark uses 2,097,152 interleaved, high-cardinality `i64` values in 256 zones of 8,192 rows. Interleaving makes the point lookup fall inside every zone's min/max range, so ordinary range statistics cannot skip a zone. Timings are medians of nine fresh-reader scans after two warmups. Segment payload counts are measured below the file reader with a counting `SegmentSource`. - -| Case | Zones pruned | File size | Median | Segment requests | Unique segments | Segment payload | -| --- | ---: | ---: | ---: | ---: | ---: | ---: | -| No bloom | 0/256 | 16,845,924 B | 1.541 ms | 258 | 257 | 16,897,520 B | -| Bloom | 251/256 | 18,947,380 B | 0.571 ms | 7 | 6 | 2,504,196 B | - -The bloom scan was **2.70x faster** and moved **6.75x less segment payload**. A preceding clean run measured 2.57x, giving a 2.57-2.70x repeat range. Five zones survived: the one true hit and four bloom false positives. The file grew by 2,101,456 bytes, or 12.5%, from one 8 KiB bloom per zone plus layout overhead. - -The negative control uses cardinality 16 and queries a value present in every zone: - -| Case | Zones pruned | File size | Median | Segment requests | Unique segments | Segment payload | -| --- | ---: | ---: | ---: | ---: | ---: | ---: | -| No bloom | 0/256 | 16,843,876 B | 1.807 ms | 513 | 257 | 33,651,004 B | -| Bloom | 0/256 | 18,945,332 B | 1.832 ms | 513 | 257 | 35,752,396 B | - -With nothing to prune, the indexed scan was 1.4% slower (0.986x) and caused 6.2% more segment payload. A bloom only makes sense when avoided data reads pay for loading and probing the zone table; it does not help low-cardinality or non-selective equality predicates. - -These are in-memory buffer results from a focused release test, not storage-system throughput claims. Remote or high-latency storage should amplify the request reduction, while a very fast cached scan or smaller zones may make zone-map overhead more visible. - -### Existing SQL suites - -The benchmark branch also wires the indexes into the normal compressed writer without replacing -its dictionary, compressor, coalescing, or flat-layout stages: - -- ClickBench `UserID`: equality bloom, 8,192-row zones. -- ClickBench `URL` and `Title`: 16 KiB trigram blooms, 8,192-row zones. -- FineWeb `url`, `text`, and `file_path`: 16 KiB trigram blooms, 1,024-row zones. - -The benchmark artifact is written to a separate `vortex-file-skipping` directory so cached normal -Vortex files cannot contaminate the comparison. The focused draft-PR run covers ClickBench, -ClickBench sorted, FineWeb NVMe, and FineWeb S3. Each candidate job times unchanged -DataFusion/Parquet alongside indexed DataFusion/Vortex, and the reporting harness subtracts the -Parquet control shift from the attributed Vortex impact. The controlled run is -[29951570194](https://github.com/vortex-data/vortex/actions/runs/29951570194). - -| Suite | Vortex PR/base | Parquet control | Attributed Vortex impact | Indexed Vortex size | Verdict | -| --- | ---: | ---: | ---: | ---: | --- | -| ClickBench NVMe | 1.004x | 1.014x | -1.0% | +4.3% | No suite-wide signal | -| ClickBench sorted NVMe | 0.944x | 0.991x | -4.8% | +2.9% | No clear signal; noisy | -| FineWeb NVMe | 1.125x | 1.039x | +8.3% | +3.3% | No clear suite-wide signal; indexed queries regress | -| FineWeb S3 | 1.057x | 0.984x | +7.5% | not captured | No clear signal; environment noisy | - -The equality result does materialize in a real benchmark. ClickBench q19 is the indexed -`UserID = 435090932899640449` point lookup. It improves from 29.23 ms to 21.42 ms, a raw 27% -reduction. The matching unchanged Parquet query moves from 28.17 ms to 27.45 ms, so the -control-adjusted Vortex ratio is approximately `0.73 / 0.97 = 0.75`: **about 25% faster, or -1.33x**. The diagnostic mask explains the timing: one scan examines 12,299 zones across 100 files, -proves 12,294 absent, and keeps only five. The entire 43-query ClickBench suite remains neutral -because the other queries cannot use this point-lookup index. The +4.3% file-size cost includes the -`UserID` bloom and two larger trigram indexes, so it is not the isolated equality-index overhead. -The full tables are in the -[ClickBench benchmark comment](https://github.com/vortex-data/vortex/pull/8907#issuecomment-5050548603). - -The trigram result is negative. FineWeb's one 1,023-zone table produced these masks: - -| Predicate | Indexed column | Zones pruned | Average fill | -| --- | --- | ---: | ---: | -| `url LIKE '%google%'` | `url` | 21/1,023 | 35.4% | -| `text LIKE '%Google%'` | `text` | 0/1,023 | 79.9% | -| `text LIKE '% vortex %'` | `text` | 0/1,023 | 79.9% | -| `url LIKE '%espn%'` | `url` | 328/1,023 | 35.4% | -| `url LIKE '%www.espn.go.com%'` | `url` | 625/1,023 | 35.4% | -| `url LIKE '%espn.go.com%'` | `url` | 607/1,023 | 35.4% | -| `file_path LIKE '%/CC-MAIN-2014-%'` | `file_path` | 0/1,023 | 5.6% | - -Despite real pruning, every trigram-targeted FineWeb query is slower after its matching Parquet -control adjustment. On NVMe the adjusted costs are approximately +21% for q3, +3% for q5, +10% -for q6, +25% for q7, and +36% for q8. On S3 they are approximately +13%, +28%, +15%, +19%, and -+27%. The 16 KiB text filter is nearly saturated, while the low-fill `file_path` case shows a -different weakness: all of the common three-byte grams occur somewhere in every zone, even though -the complete string does not. Reading and probing the index then adds cost without avoiding enough -data. See the [FineWeb NVMe](https://github.com/vortex-data/vortex/pull/8907#issuecomment-5050536937) -and [FineWeb S3](https://github.com/vortex-data/vortex/pull/8907#issuecomment-5050543034) reports. - -ClickBench q20-q23 and sorted q23 contain the expected `URL`/`Title` substring predicates, but the -trigram probe executes zero times in both job logs. Their expression arrives in a shape not matched -by this rewrite, so any timing movement is not evidence for the index. In particular, sorted q23's -raw 20% improvement cannot be credited to trigram pruning. The -[sorted report](https://github.com/vortex-data/vortex/pull/8907#issuecomment-5050576221) is retained -as a useful expression-pushdown gap to investigate. - -The SQL harness reports wall time, file size, and the added zone-mask diagnostics, but not -per-query segment requests or payload bytes. Those I/O counters therefore come from the controlled -synthetic benchmark above; the real-suite claim is intentionally limited to wall time, file size, -and zones pruned. - -### Off-the-shelf pieces - -Arrow Rust's `parquet::bloom_filter::Sbbf` is a credible production filter primitive: it has stable -split-block Bloom semantics, byte serialization, membership checks, NDV/FPP sizing, and folding. -It does not replace the Vortex-specific aggregate persistence, stats rewrite, probe expression, or -per-column writer declaration, and using it directly from `vortex-layout` would add an undesirable -Parquet dependency to the layout crate. The prototype therefore keeps a small mergeable bitset for -the experiment. For production, put an SBBF-compatible filter in a small neutral crate (or extract -the Arrow implementation) and reuse it for both equality and n-gram indexes. Pulling in a search -engine or tokenizer such as Tantivy only to enumerate byte trigrams would be substantially heavier -than the operation itself. - -## Interface recommendation - -Keep the aggregate, scalar function, and rewrite registries composable, but add a single bundle at the user-facing registration and write-declaration boundary: - -```rust -pub trait SkipIndex: Debug + Send + Sync + 'static { - fn aggregate_fn(&self, input_dtype: &DType) -> Option; - fn register(&self, session: &VortexSession); -} - -impl ZonedLayoutOptions { - pub fn with_skip_index( - self, - index: &I, - input_dtype: &DType, - session: &VortexSession, - ) -> VortexResult; -} -``` - -This is preferable to replacing the existing extension points with one large vtable. An index -author can still reuse built-in aggregates or probes, while callers have one object to register on -writer and reader sessions and one explicit per-column declaration. The prototype adds -`WriteStrategyBuilder::with_field_zoned_options` as the narrow per-column seam, retaining the normal -data pipeline instead of requiring callers to reconstruct it. - -## Awkward parts and next steps - -- Aggregate identity includes serialized options. The reader must register a rewrite using exactly the same bloom sizing and hash count as the writer; a mismatched index safely becomes inconclusive, but discovery should be automatic in a production API. -- Choosing filter size, hash count, gram size, and zone length is workload-specific. In particular, - trigrams for large text zones can saturate, while very small zones impose substantial file-size - and metadata overhead. -- Unknown aggregate fallback currently disables that zone map rather than using the known statistics alongside it. That is safe but leaves pruning performance on the table. -- The hash algorithm, format version, supported dtypes, null semantics, sizing policy, and saturation behavior need an explicit compatibility contract. This spike supports `i64` equality and byte-oriented UTF-8 trigrams only. -- Bloom stats for all zones are regular child-array data. Their layout and caching should be profiled for many indexed columns and remote reads. - -Recommendation: pursue the bundle interface and an explicit per-column writer declaration while -retaining the three lower-level registries. A production-quality equality Bloom experiment is -justified for opt-in, high-cardinality point-lookups: it is correct, prunes as expected, and speeds -up ClickBench q19 after a same-run control. Do not pursue this generic fixed-size trigram Bloom as -the production design. First fix rewrite coverage for the actual ClickBench expression shape, then -prototype adaptive sizing/saturation cutoffs and a rarer-token or sequence-aware representation -that can reject zones when common individual trigrams cannot. Any successor should have to beat the -current negative FineWeb results, including index bytes and probe cost, before being enabled. diff --git a/scripts/capture-file-sizes.py b/scripts/capture-file-sizes.py index 66bc9b5925c..3bbfaf0f986 100644 --- a/scripts/capture-file-sizes.py +++ b/scripts/capture-file-sizes.py @@ -43,10 +43,10 @@ def main(): # Formats to capture (vortex formats only, not parquet/duckdb) # Note: "vortex" CLI arg maps to "vortex-file-compressed" directory name - experimental_vortex_dir = os.environ.get("VORTEX_EXPERIMENTAL_SKIP_INDEX_DIR") + bloom_vortex_dir = os.environ.get("VORTEX_BLOOM_SKIP_INDEX_DIR") formats_to_capture = {"vortex-file-compressed", "vortex-compact"} - if experimental_vortex_dir: - formats_to_capture.add(experimental_vortex_dir) + if bloom_vortex_dir: + formats_to_capture.add(bloom_vortex_dir) records = [] @@ -61,7 +61,7 @@ def main(): directory_name = format_dir.name if directory_name not in formats_to_capture: continue - format_name = "vortex-file-compressed" if directory_name == experimental_vortex_dir else directory_name + format_name = "vortex-file-compressed" if directory_name == bloom_vortex_dir else directory_name # Extract scale factor from path (e.g., "1.0" for tpch/1.0/vortex-file-compressed) # Default to "1.0" if no intermediate directory (e.g., clickbench) diff --git a/vortex-bench/src/conversions.rs b/vortex-bench/src/conversions.rs index 496e702fe9b..8fae7c9d4f6 100644 --- a/vortex-bench/src/conversions.rs +++ b/vortex-bench/src/conversions.rs @@ -51,7 +51,6 @@ use vortex::layout::LayoutStrategy; use vortex::layout::layouts::chunked::writer::ChunkedLayoutStrategy; use vortex::layout::layouts::compressed::CompressingStrategy; use vortex::layout::layouts::flat::writer::FlatLayoutStrategy; -use vortex::layout::layouts::zoned::experimental::SkipIndex; use vortex::layout::layouts::zoned::writer::ZonedLayoutOptions; use vortex::session::VortexSession; use vortex::utils::aliases::hash_set::HashSet; @@ -67,10 +66,8 @@ use wkb::writer::write_geometry; use crate::CompactionStrategy; use crate::Format; use crate::SESSION; -use crate::experimental_bloom_index; -use crate::experimental_ngram_index; -use crate::experimental_ngram_zone_len; -use crate::experimental_skip_indexes_enabled; +use crate::bloom_skip_index; +use crate::bloom_skip_index_enabled; use crate::format_data_dir; use crate::utils::file::idempotent_async; @@ -201,8 +198,7 @@ fn write_options_for( _ => Vec::new(), }; if binary_fields.is_empty() - && (!experimental_skip_indexes_enabled() - || matches!(compaction, CompactionStrategy::Compact)) + && (!bloom_skip_index_enabled() || matches!(compaction, CompactionStrategy::Compact)) { return Ok(compaction.apply_options(SESSION.write_options())); } @@ -216,28 +212,22 @@ fn write_options_for( builder = builder.with_field_writer(FieldPath::from_name(name), no_dict_layout()); } - if experimental_skip_indexes_enabled() + if bloom_skip_index_enabled() && matches!(compaction, CompactionStrategy::Default) && let DType::Struct(fields, _) = dtype { - let exact = experimental_bloom_index(); - let ngram = experimental_ngram_index(); + let index = bloom_skip_index(); for (name, field_dtype) in fields.names().iter().zip(fields.fields()) { - let (index, block_size): (&dyn SkipIndex, _) = match (name.as_ref(), &field_dtype) { - ("UserID", DType::Primitive(PType::I64, _)) => ( - &exact, - std::num::NonZeroUsize::new(8192).unwrap_or(std::num::NonZeroUsize::MIN), - ), - ("URL" | "Title" | "url" | "text" | "file_path", DType::Utf8(_)) => { - (&ngram, experimental_ngram_zone_len()) - } - _ => continue, - }; + if name.as_ref() != "UserID" || !matches!(field_dtype, DType::Primitive(PType::I64, _)) + { + continue; + } let options = ZonedLayoutOptions { - block_size, + block_size: std::num::NonZeroUsize::new(8192) + .unwrap_or(std::num::NonZeroUsize::MIN), ..Default::default() } - .with_skip_index(index, &field_dtype, &SESSION)?; + .with_skip_index(&index, &field_dtype, &SESSION)?; builder = builder.with_field_zoned_options(FieldPath::from_name(name.clone()), options); } } diff --git a/vortex-bench/src/lib.rs b/vortex-bench/src/lib.rs index 3148c84d481..1ce66a925dd 100644 --- a/vortex-bench/src/lib.rs +++ b/vortex-bench/src/lib.rs @@ -6,8 +6,6 @@ use std::clone::Clone; use std::fmt::Display; -use std::num::NonZeroU8; -use std::num::NonZeroUsize; use std::str::FromStr; use std::sync::LazyLock; @@ -74,11 +72,8 @@ pub use output::create_output_writer; use vortex::VortexSessionDefault; pub use vortex::error::vortex_panic; use vortex::io::session::RuntimeSessionExt; -use vortex::layout::layouts::zoned::experimental::BloomOptions; -use vortex::layout::layouts::zoned::experimental::BloomSkipIndex; -use vortex::layout::layouts::zoned::experimental::NGramBloomOptions; -use vortex::layout::layouts::zoned::experimental::NGramBloomSkipIndex; -use vortex::layout::layouts::zoned::experimental::SkipIndex; +use vortex::layout::layouts::zoned::skip_index::BloomSkipIndex; +use vortex::layout::layouts::zoned::skip_index::SkipIndex; use vortex::session::VortexSession; // All benchmarks run with mimalloc for consistency. @@ -88,68 +83,26 @@ static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; pub static SESSION: LazyLock = LazyLock::new(|| { let session = VortexSession::default().with_tokio(); vortex_geo::initialize(&session); - if experimental_skip_indexes_enabled() { - experimental_bloom_index().register(&session); - experimental_ngram_index().register(&session); + if bloom_skip_index_enabled() { + bloom_skip_index().register(&session); } session }); -/// Whether benchmark files should be written and read with the experimental skipping indexes. -pub fn experimental_skip_indexes_enabled() -> bool { - std::env::var("VORTEX_EXPERIMENTAL_SKIP_INDEX").is_ok_and(|value| value == "1") +/// Whether benchmark files should be written and read with the Bloom skipping index. +pub(crate) fn bloom_skip_index_enabled() -> bool { + std::env::var("VORTEX_BLOOM_SKIP_INDEX").is_ok_and(|value| value == "1") } -/// Exact-value bloom index used by the benchmark experiment. -pub fn experimental_bloom_index() -> BloomSkipIndex { +/// Exact-value Bloom index used by the benchmark. +pub(crate) fn bloom_skip_index() -> BloomSkipIndex { BloomSkipIndex::default() } -/// N-gram bloom index used by the benchmark experiment, with environment-tunable sizing. -pub fn experimental_ngram_index() -> NGramBloomSkipIndex { - let defaults = NGramBloomOptions::default(); - let bytes = - experimental_nonzero_usize("VORTEX_EXPERIMENTAL_NGRAM_BYTES", defaults.bloom().bytes()); - let hashes = experimental_nonzero_u8( - "VORTEX_EXPERIMENTAL_NGRAM_HASHES", - defaults.bloom().hashes(), - ); - let gram_size = experimental_nonzero_u8("VORTEX_EXPERIMENTAL_NGRAM_SIZE", defaults.gram_size()); - NGramBloomSkipIndex::new(NGramBloomOptions::new( - BloomOptions::new(bytes, hashes), - gram_size, - )) -} - -/// Zone length used by string n-gram indexes in the benchmark experiment. -pub fn experimental_ngram_zone_len() -> NonZeroUsize { - experimental_nonzero_usize( - "VORTEX_EXPERIMENTAL_NGRAM_ZONE_LEN", - NonZeroUsize::new(1024).unwrap_or(NonZeroUsize::MIN), - ) -} - -fn experimental_nonzero_usize(name: &str, default: NonZeroUsize) -> NonZeroUsize { - std::env::var(name) - .ok() - .and_then(|value| value.parse().ok()) - .and_then(NonZeroUsize::new) - .unwrap_or(default) -} - -fn experimental_nonzero_u8(name: &str, default: NonZeroU8) -> NonZeroU8 { - std::env::var(name) - .ok() - .and_then(|value| value.parse().ok()) - .and_then(NonZeroU8::new) - .unwrap_or(default) -} - /// Physical data directory for a benchmark format, with an optional side-by-side local override. pub fn format_data_dir(format: Format) -> String { - if format == Format::OnDiskVortex && experimental_skip_indexes_enabled() { - std::env::var("VORTEX_EXPERIMENTAL_SKIP_INDEX_DIR") - .unwrap_or_else(|_| format.name().to_string()) + if format == Format::OnDiskVortex && bloom_skip_index_enabled() { + std::env::var("VORTEX_BLOOM_SKIP_INDEX_DIR").unwrap_or_else(|_| format.name().to_string()) } else { format.name().to_string() } diff --git a/vortex-file/src/strategy.rs b/vortex-file/src/strategy.rs index 1e9dd6eafb4..86efdf2523e 100644 --- a/vortex-file/src/strategy.rs +++ b/vortex-file/src/strategy.rs @@ -221,8 +221,8 @@ impl WriteStrategyBuilder { /// Override only the zoned-statistics options for a field while retaining the default /// repartitioning, dictionary, compression, buffering, and flat-layout pipeline. /// - /// This is an experimental seam for attaching custom per-zone aggregates without changing the - /// physical data strategy used for controlled comparisons. + /// This can attach custom per-zone aggregates without changing the physical data strategy for + /// the field. pub fn with_field_zoned_options( mut self, field: impl Into, diff --git a/vortex-file/tests/experimental_bloom.rs b/vortex-file/tests/bloom_skip_index.rs similarity index 76% rename from vortex-file/tests/experimental_bloom.rs rename to vortex-file/tests/bloom_skip_index.rs index 6b505566c78..4fdeaf15701 100644 --- a/vortex-file/tests/experimental_bloom.rs +++ b/vortex-file/tests/bloom_skip_index.rs @@ -3,7 +3,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors -//! End-to-end correctness evidence for the experimental zoned bloom skipping index. +//! End-to-end correctness evidence for the zoned Bloom skipping index. use std::num::NonZeroU8; use std::num::NonZeroUsize; @@ -21,7 +21,6 @@ use vortex_array::VortexSessionExecute; use vortex_array::arrays::ChunkedArray; use vortex_array::arrays::PrimitiveArray; use vortex_array::arrays::StructArray; -use vortex_array::arrays::VarBinViewArray; use vortex_array::assert_arrays_eq; use vortex_array::dtype::DType; use vortex_array::dtype::Nullability; @@ -32,9 +31,6 @@ use vortex_array::expr::get_item; use vortex_array::expr::lit; use vortex_array::expr::root; use vortex_array::field_path; -use vortex_array::scalar_fn::ScalarFnVTableExt; -use vortex_array::scalar_fn::fns::like::Like; -use vortex_array::scalar_fn::fns::like::LikeOptions; use vortex_array::stream::ArrayStreamExt; use vortex_error::VortexResult; use vortex_file::OpenOptionsSessionExt; @@ -42,15 +38,10 @@ use vortex_file::WriteOptionsSessionExt; use vortex_file::WriteStrategyBuilder; use vortex_io::session::RuntimeSession; use vortex_layout::LayoutStrategy; -use vortex_layout::layouts::chunked::writer::ChunkedLayoutStrategy; -use vortex_layout::layouts::flat::writer::FlatLayoutStrategy; -use vortex_layout::layouts::table::TableStrategy; -use vortex_layout::layouts::zoned::experimental::BloomOptions; -use vortex_layout::layouts::zoned::experimental::BloomSkipIndex; -use vortex_layout::layouts::zoned::experimental::NGramBloomSkipIndex; -use vortex_layout::layouts::zoned::experimental::SkipIndex; +use vortex_layout::layouts::zoned::skip_index::BloomOptions; +use vortex_layout::layouts::zoned::skip_index::BloomSkipIndex; +use vortex_layout::layouts::zoned::skip_index::SkipIndex; use vortex_layout::layouts::zoned::writer::ZonedLayoutOptions; -use vortex_layout::layouts::zoned::writer::ZonedStrategy; use vortex_layout::segments::SegmentFuture; use vortex_layout::segments::SegmentId; use vortex_layout::segments::SegmentSource; @@ -143,13 +134,6 @@ fn filter(value: i64) -> Expression { eq(get_item("id", root()), lit(value)) } -fn like_filter(pattern: &str) -> Expression { - Like.new_expr( - LikeOptions::default(), - [get_item("text", root()), lit(pattern)], - ) -} - fn strategy( session: &VortexSession, index: Option<&dyn SkipIndex>, @@ -162,28 +146,14 @@ fn strategy( if let Some(index) = index { options = options.with_skip_index(index, &PType::I64.into(), session)?; } - let zoned = ZonedStrategy::new( - ChunkedLayoutStrategy::new(FlatLayoutStrategy::default()), - FlatLayoutStrategy::default(), - options, - ); - let flat: Arc = Arc::new(FlatLayoutStrategy::default()); - Ok(Arc::new( - TableStrategy::new(Arc::clone(&flat), flat) - .with_field_writer(field_path!(id), Arc::new(zoned)), - )) + Ok(WriteStrategyBuilder::default() + .with_field_zoned_options(field_path!(id), options) + .build()) } async fn scan(file: &vortex_file::VortexFile, value: i64) -> VortexResult { - scan_expression(file, filter(value)).await -} - -async fn scan_expression( - file: &vortex_file::VortexFile, - filter: Expression, -) -> VortexResult { file.scan()? - .with_filter(filter) + .with_filter(filter(value)) .into_array_stream()? .read_all() .await @@ -204,53 +174,6 @@ async fn write_file( Ok(bytes) } -fn ngram_data() -> ArrayRef { - let chunks = [ - ["alpha google result", "alpha", "search", "result"], - ["beta page", "beta", "another", "page"], - ["vortex storage", "columnar", "format", "rust"], - ["omega", "ending", "document", "text"], - ] - .into_iter() - .map(|values| { - StructArray::from_fields(&[("text", VarBinViewArray::from_iter_str(values).into_array())]) - .expect("valid string test struct") - .into_array() - }) - .collect::>(); - ChunkedArray::try_new( - chunks, - DType::struct_( - [("text", DType::Utf8(Nullability::NonNullable))], - Nullability::NonNullable, - ), - ) - .expect("valid chunked string data") - .into_array() -} - -async fn write_ngram_file( - session: &VortexSession, - input: &ArrayRef, - index: &NGramBloomSkipIndex, -) -> VortexResult> { - let options = ZonedLayoutOptions { - block_size: NonZeroUsize::new(4).expect("zone length is non-zero"), - ..Default::default() - } - .with_skip_index(index, &DType::Utf8(Nullability::NonNullable), session)?; - let strategy = WriteStrategyBuilder::default() - .with_field_zoned_options(field_path!(text), options) - .build(); - let mut bytes = Vec::new(); - session - .write_options() - .with_strategy(strategy) - .write(&mut bytes, input.to_array_stream()) - .await?; - Ok(bytes) -} - #[tokio::test] async fn bloom_roundtrip_prunes_and_unknown_reader_matches_full_scan() -> VortexResult<()> { let index = bloom(); @@ -327,64 +250,6 @@ async fn bloom_roundtrip_prunes_and_unknown_reader_matches_full_scan() -> Vortex Ok(()) } -#[tokio::test] -async fn ngram_roundtrip_prunes_substring_like_and_matches_full_scan() -> VortexResult<()> { - let index = NGramBloomSkipIndex::default(); - let write_session = session(Some(&index)); - let input = ngram_data(); - let bytes = write_ngram_file(&write_session, &input, &index).await?; - - let read_session = session(Some(&index)); - let file = read_session.open_options().open_buffer(bytes.clone())?; - let reader = file.layout_reader()?; - let row_count = file.row_count(); - - let hit_mask = reader - .pruning_evaluation( - &(0..row_count), - &like_filter("%google%"), - Mask::new_true(usize::try_from(row_count)?), - )? - .await?; - assert!(hit_mask.iter().take(4).all(|keep| keep)); - assert!(hit_mask.iter().skip(4).all(|keep| !keep)); - - let miss_mask = reader - .pruning_evaluation( - &(0..row_count), - &like_filter("%missing-needle%"), - Mask::new_true(usize::try_from(row_count)?), - )? - .await?; - assert!( - miss_mask.all_false(), - "an absent substring should prune every zone" - ); - - let full_scan_session = session(None); - full_scan_session.allow_unknown(); - let full_scan_file = full_scan_session.open_options().open_buffer(bytes)?; - - let indexed_hit = scan_expression(&file, like_filter("%google%")).await?; - let full_scan_hit = scan_expression(&full_scan_file, like_filter("%google%")).await?; - assert_arrays_eq!( - indexed_hit, - full_scan_hit, - &mut read_session.create_execution_ctx() - ); - assert_eq!(indexed_hit.len(), 1); - - let indexed_miss = scan_expression(&file, like_filter("%missing-needle%")).await?; - let full_scan_miss = scan_expression(&full_scan_file, like_filter("%missing-needle%")).await?; - assert_arrays_eq!( - indexed_miss, - full_scan_miss, - &mut read_session.create_execution_ctx() - ); - assert_eq!(indexed_miss.len(), 0); - Ok(()) -} - #[derive(Default)] struct ReadCounts { requests: AtomicU64, @@ -460,12 +325,12 @@ fn median(runs: &mut [BenchRun]) -> &BenchRun { &runs[runs.len() / 2] } -/// Focused spike benchmark. Run with: +/// Focused Bloom benchmark. Run with: /// -/// `cargo test --release -p vortex-file --test experimental_bloom bloom_point_lookup_benchmark +/// `cargo test --release -p vortex-file --test bloom_skip_index bloom_point_lookup_benchmark /// -- --ignored --nocapture` #[tokio::test] -#[ignore = "release-only experimental benchmark"] +#[ignore = "release-only benchmark"] async fn bloom_point_lookup_benchmark() -> VortexResult<()> { const BENCH_ZONE_LEN: usize = 8192; const BENCH_NZONES: usize = 256; diff --git a/vortex-layout/src/layouts/zoned/experimental/ngram.rs b/vortex-layout/src/layouts/zoned/experimental/ngram.rs deleted file mode 100644 index 7802beb8f8f..00000000000 --- a/vortex-layout/src/layouts/zoned/experimental/ngram.rs +++ /dev/null @@ -1,518 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors - -use std::fmt; -use std::fmt::Display; -use std::fmt::Formatter; -use std::num::NonZeroU8; - -use vortex_array::ArrayRef; -use vortex_array::Columnar; -use vortex_array::ExecutionCtx; -use vortex_array::IntoArray; -use vortex_array::aggregate_fn::AggregateFnId; -use vortex_array::aggregate_fn::AggregateFnRef; -use vortex_array::aggregate_fn::AggregateFnVTable; -use vortex_array::aggregate_fn::AggregateFnVTableExt; -use vortex_array::aggregate_fn::session::AggregateFnSessionExt; -use vortex_array::arrays::BoolArray; -use vortex_array::arrays::ConstantArray; -use vortex_array::arrays::VarBinViewArray; -use vortex_array::arrays::varbinview::VarBinViewArrayExt; -use vortex_array::dtype::DType; -use vortex_array::dtype::Nullability; -use vortex_array::expr::Expression; -use vortex_array::expr::is_root; -use vortex_array::expr::not; -use vortex_array::scalar::Scalar; -use vortex_array::scalar_fn::Arity; -use vortex_array::scalar_fn::ChildName; -use vortex_array::scalar_fn::ExecutionArgs; -use vortex_array::scalar_fn::ScalarFnId; -use vortex_array::scalar_fn::ScalarFnVTable; -use vortex_array::scalar_fn::ScalarFnVTableExt; -use vortex_array::scalar_fn::fns::like::Like; -use vortex_array::scalar_fn::fns::literal::Literal; -use vortex_array::scalar_fn::session::ScalarFnSessionExt; -use vortex_array::stats::rewrite::StatsRewriteCtx; -use vortex_array::stats::rewrite::StatsRewriteRule; -use vortex_array::stats::session::StatsSessionExt; -use vortex_array::stats::stat; -use vortex_buffer::BitBuffer; -use vortex_error::VortexResult; -use vortex_error::vortex_ensure; -use vortex_error::vortex_err; -use vortex_session::VortexSession; -use vortex_session::registry::CachedId; - -use super::BloomOptions; -use super::BloomPartial; -use super::SkipIndex; -use super::bloom_contains_bytes; -use super::bloom_insert_bytes; -use super::diagnostics_enabled; - -/// Persisted tuning for a bloom filter populated with every byte n-gram in a UTF-8 zone. -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub struct NGramBloomOptions { - bloom: BloomOptions, - gram_size: NonZeroU8, -} - -impl NGramBloomOptions { - /// Create n-gram bloom options. - pub fn new(bloom: BloomOptions, gram_size: NonZeroU8) -> Self { - Self { bloom, gram_size } - } - - /// Bloom sizing and hash-count options. - pub fn bloom(&self) -> &BloomOptions { - &self.bloom - } - - /// Number of UTF-8 bytes in each indexed gram. - pub fn gram_size(&self) -> NonZeroU8 { - self.gram_size - } -} - -impl Default for NGramBloomOptions { - fn default() -> Self { - Self { - bloom: BloomOptions::new( - std::num::NonZeroUsize::new(64 * 1024).unwrap_or(std::num::NonZeroUsize::MIN), - NonZeroU8::new(5).unwrap_or(NonZeroU8::MIN), - ), - gram_size: NonZeroU8::new(3).unwrap_or(NonZeroU8::MIN), - } - } -} - -impl Display for NGramBloomOptions { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - write!(f, "{},gram_size={}", self.bloom, self.gram_size) - } -} - -/// Experimental byte n-gram bloom index for case-sensitive SQL `LIKE` predicates. -#[derive(Clone, Debug, Default)] -pub struct NGramBloomSkipIndex { - options: NGramBloomOptions, -} - -impl NGramBloomSkipIndex { - /// Create an index with explicit bloom and gram tuning. - pub fn new(options: NGramBloomOptions) -> Self { - Self { options } - } - - /// Persisted index options. - pub fn options(&self) -> &NGramBloomOptions { - &self.options - } -} - -impl SkipIndex for NGramBloomSkipIndex { - fn aggregate_fn(&self, input_dtype: &DType) -> Option { - NGramBloomFilter - .return_dtype(&self.options, input_dtype) - .map(|_| NGramBloomFilter.bind(self.options.clone())) - } - - fn register(&self, session: &VortexSession) { - session.aggregate_fns().register(NGramBloomFilter); - session.scalar_fns().register(NGramBloomContains); - session.stats().register_rewrite(NGramLikeRewrite { - options: self.options.clone(), - }); - } -} - -#[derive(Clone, Debug)] -struct NGramBloomFilter; - -impl AggregateFnVTable for NGramBloomFilter { - type Options = NGramBloomOptions; - type Partial = BloomPartial; - - fn id(&self) -> AggregateFnId { - static ID: CachedId = CachedId::new("vortex.experimental.ngram_bloom.utf8.v1"); - *ID - } - - fn serialize(&self, options: &Self::Options) -> VortexResult>> { - let bytes = u32::try_from(options.bloom.bytes().get())?; - let mut metadata = bytes.to_le_bytes().to_vec(); - metadata.push(options.bloom.hashes().get()); - metadata.push(options.gram_size.get()); - Ok(Some(metadata)) - } - - fn deserialize( - &self, - metadata: &[u8], - _session: &VortexSession, - ) -> VortexResult { - vortex_ensure!(metadata.len() == 6, "invalid n-gram bloom metadata length"); - let bytes = u32::from_le_bytes([metadata[0], metadata[1], metadata[2], metadata[3]]); - Ok(NGramBloomOptions::new( - BloomOptions::new( - std::num::NonZeroUsize::new(usize::try_from(bytes)?) - .ok_or_else(|| vortex_err!("n-gram bloom byte length must be non-zero"))?, - NonZeroU8::new(metadata[4]) - .ok_or_else(|| vortex_err!("n-gram bloom hash count must be non-zero"))?, - ), - NonZeroU8::new(metadata[5]) - .ok_or_else(|| vortex_err!("n-gram size must be non-zero"))?, - )) - } - - fn return_dtype(&self, _options: &Self::Options, input_dtype: &DType) -> Option { - matches!(input_dtype, DType::Utf8(_)).then_some(DType::Binary(Nullability::NonNullable)) - } - - fn partial_dtype(&self, options: &Self::Options, input_dtype: &DType) -> Option { - self.return_dtype(options, input_dtype) - } - - fn empty_partial( - &self, - options: &Self::Options, - _input_dtype: &DType, - ) -> VortexResult { - Ok(BloomPartial { - bits: vec![0; options.bloom.bytes().get()], - hashes: options.bloom.hashes().get(), - gram_size: Some(options.gram_size.get()), - }) - } - - fn combine_partials(&self, partial: &mut Self::Partial, other: Scalar) -> VortexResult<()> { - if other.is_null() { - return Ok(()); - } - let other = other - .as_binary() - .value() - .ok_or_else(|| vortex_err!("non-null n-gram bloom partial has no bytes"))?; - vortex_ensure!( - partial.bits.len() == other.len(), - "n-gram bloom partial length mismatch" - ); - for (dst, src) in partial.bits.iter_mut().zip(other.as_slice()) { - *dst |= *src; - } - Ok(()) - } - - fn to_scalar(&self, partial: &Self::Partial) -> VortexResult { - Ok(Scalar::binary( - partial.bits.clone(), - Nullability::NonNullable, - )) - } - - fn reset(&self, partial: &mut Self::Partial) { - partial.bits.fill(0); - } - - fn is_saturated(&self, partial: &Self::Partial) -> bool { - partial.bits.iter().all(|byte| *byte == u8::MAX) - } - - fn accumulate( - &self, - partial: &mut Self::Partial, - batch: &Columnar, - ctx: &mut ExecutionCtx, - ) -> VortexResult<()> { - let gram_size = usize::from( - partial - .gram_size - .ok_or_else(|| vortex_err!("n-gram bloom partial is missing its gram size"))?, - ); - match batch { - Columnar::Constant(constant) => { - if let Some(value) = constant.scalar().as_utf8().value() { - insert_grams( - &mut partial.bits, - value.as_bytes(), - gram_size, - partial.hashes, - ); - } - } - Columnar::Canonical(canonical) => { - let values = canonical.as_varbinview(); - let validity = values - .varbinview_validity() - .execute_mask(values.len(), ctx)?; - for (idx, valid) in validity.iter().enumerate() { - if valid { - insert_grams( - &mut partial.bits, - values.bytes_at(idx).as_slice(), - gram_size, - partial.hashes, - ); - } - } - } - } - Ok(()) - } - - fn finalize(&self, partials: ArrayRef) -> VortexResult { - Ok(partials) - } - - fn finalize_scalar(&self, partial: &Self::Partial) -> VortexResult { - self.to_scalar(partial) - } -} - -#[derive(Clone, Debug)] -struct NGramBloomContains; - -impl ScalarFnVTable for NGramBloomContains { - type Options = NGramBloomOptions; - - fn id(&self) -> ScalarFnId { - static ID: CachedId = CachedId::new("vortex.experimental.ngram_bloom_contains.utf8.v1"); - *ID - } - - fn serialize(&self, options: &Self::Options) -> VortexResult>> { - NGramBloomFilter.serialize(options) - } - - fn deserialize(&self, metadata: &[u8], session: &VortexSession) -> VortexResult { - NGramBloomFilter.deserialize(metadata, session) - } - - fn arity(&self, _options: &Self::Options) -> Arity { - Arity::Exact(2) - } - - fn child_name(&self, _options: &Self::Options, child_idx: usize) -> ChildName { - match child_idx { - 0 => ChildName::from("filter"), - 1 => ChildName::from("pattern"), - _ => unreachable!("ngram_bloom_contains has exactly two children"), - } - } - - fn return_dtype(&self, _options: &Self::Options, args: &[DType]) -> VortexResult { - vortex_ensure!( - matches!(args[0], DType::Binary(_)), - "n-gram bloom must be Binary" - ); - vortex_ensure!( - matches!(args[1], DType::Utf8(_)), - "LIKE pattern must be Utf8" - ); - Ok(DType::Bool(args[0].nullability() | args[1].nullability())) - } - - fn execute( - &self, - options: &Self::Options, - args: &dyn ExecutionArgs, - ctx: &mut ExecutionCtx, - ) -> VortexResult { - let filters = args.get(0)?.execute::(ctx)?; - let pattern = args - .get(1)? - .as_constant() - .ok_or_else(|| vortex_err!("LIKE pattern must be constant"))?; - let Some(pattern) = pattern.as_utf8().value() else { - return Ok(ConstantArray::new( - Scalar::null(DType::Bool(Nullability::Nullable)), - args.row_count(), - ) - .into_array()); - }; - let grams = required_grams(pattern, usize::from(options.gram_size.get())); - - let validity = filters.varbinview_validity(); - let valid = validity.execute_mask(filters.len(), ctx)?; - let mut possible = Vec::with_capacity(filters.len()); - let mut set_bits = 0u64; - let mut valid_zones = 0usize; - for (idx, is_valid) in valid.iter().enumerate() { - if !is_valid { - possible.push(false); - continue; - } - let filter = filters.bytes_at(idx); - vortex_ensure!( - filter.len() == options.bloom.bytes().get(), - "stored n-gram bloom byte length does not match options" - ); - set_bits += filter - .as_slice() - .iter() - .map(|byte| u64::from(byte.count_ones())) - .sum::(); - valid_zones += 1; - possible.push(grams.iter().all(|gram| { - bloom_contains_bytes(filter.as_slice(), gram, options.bloom.hashes().get()) - })); - } - - if diagnostics_enabled() && valid_zones > 0 { - let total_bits = valid_zones as f64 * options.bloom.bytes().get() as f64 * 8.0; - let possible_zones = possible.iter().filter(|value| **value).count(); - tracing::info!( - target: "vortex_layout::skip_index", - index = "ngram_bloom", - pattern = pattern.as_str(), - gram_size = options.gram_size.get(), - grams = grams.len(), - zones = valid_zones, - definitely_absent_zones = valid_zones - possible_zones, - average_fill_ratio = set_bits as f64 / total_bits, - "experimental skip-index probe" - ); - } - - Ok(BoolArray::new(BitBuffer::from_iter(possible), validity).into_array()) - } - - fn is_null_sensitive(&self, _options: &Self::Options) -> bool { - false - } - - fn is_fallible(&self, _options: &Self::Options) -> bool { - true - } -} - -#[derive(Clone, Debug)] -struct NGramLikeRewrite { - options: NGramBloomOptions, -} - -impl StatsRewriteRule for NGramLikeRewrite { - fn scalar_fn_id(&self) -> ScalarFnId { - Like.id() - } - - fn falsify( - &self, - expr: &Expression, - ctx: &StatsRewriteCtx<'_>, - ) -> VortexResult> { - let like = expr.as_::(); - if like.negated || like.case_insensitive || !is_root(expr.child(0)) { - return Ok(None); - } - let Some(pattern) = expr.child(1).as_opt::() else { - return Ok(None); - }; - let Some(pattern_value) = pattern.as_utf8().value() else { - return Ok(None); - }; - if !matches!(ctx.return_dtype(expr.child(0))?, DType::Utf8(_)) - || required_grams(pattern_value, usize::from(self.options.gram_size.get())).is_empty() - { - return Ok(None); - } - - let filter = stat( - expr.child(0).clone(), - NGramBloomFilter.bind(self.options.clone()), - ); - let contains = - NGramBloomContains.new_expr(self.options.clone(), [filter, expr.child(1).clone()]); - Ok(Some(not(contains))) - } -} - -fn insert_grams(bits: &mut [u8], value: &[u8], gram_size: usize, hashes: u8) { - for gram in value.windows(gram_size) { - bloom_insert_bytes(bits, gram, hashes); - } -} - -fn required_grams(pattern: &str, gram_size: usize) -> Vec> { - literal_runs(pattern) - .into_iter() - .flat_map(|run| { - run.windows(gram_size) - .map(<[u8]>::to_vec) - .collect::>() - }) - .collect() -} - -fn literal_runs(pattern: &str) -> Vec> { - let mut runs = Vec::new(); - let mut current = Vec::new(); - let mut chars = pattern.chars(); - while let Some(character) = chars.next() { - match character { - '\\' => { - if let Some(escaped) = chars.next() { - let mut bytes = [0; 4]; - current.extend_from_slice(escaped.encode_utf8(&mut bytes).as_bytes()); - } else { - current.push(b'\\'); - } - } - '%' | '_' => { - if !current.is_empty() { - runs.push(std::mem::take(&mut current)); - } - } - character => { - let mut bytes = [0; 4]; - current.extend_from_slice(character.encode_utf8(&mut bytes).as_bytes()); - } - } - } - if !current.is_empty() { - runs.push(current); - } - runs -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn extracts_only_required_literal_grams() { - assert_eq!( - required_grams(r"%foo_bar\%baz%", 3), - [ - b"foo".to_vec(), - b"bar".to_vec(), - b"ar%".to_vec(), - b"r%b".to_vec(), - b"%ba".to_vec(), - b"baz".to_vec() - ] - ); - assert!(required_grams("%a%b%", 3).is_empty()); - } - - #[test] - fn gram_membership_has_no_false_negatives() { - let options = NGramBloomOptions::default(); - let mut bits = vec![0; options.bloom.bytes().get()]; - insert_grams( - &mut bits, - b"the quick brown fox", - usize::from(options.gram_size.get()), - options.bloom.hashes().get(), - ); - for gram in b"quick".windows(3) { - assert!(bloom_contains_bytes( - &bits, - gram, - options.bloom.hashes().get() - )); - } - } -} diff --git a/vortex-layout/src/layouts/zoned/mod.rs b/vortex-layout/src/layouts/zoned/mod.rs index db100d5aaf0..72e4b67bbb5 100644 --- a/vortex-layout/src/layouts/zoned/mod.rs +++ b/vortex-layout/src/layouts/zoned/mod.rs @@ -12,10 +12,10 @@ // SPDX-FileCopyrightText: Copyright the Vortex contributors mod builder; -pub mod experimental; mod pruning; mod reader; mod schema; +pub mod skip_index; pub mod writer; pub mod zone_map; diff --git a/vortex-layout/src/layouts/zoned/experimental.rs b/vortex-layout/src/layouts/zoned/skip_index.rs similarity index 91% rename from vortex-layout/src/layouts/zoned/experimental.rs rename to vortex-layout/src/layouts/zoned/skip_index.rs index 2997e054aaf..3e5c6958553 100644 --- a/vortex-layout/src/layouts/zoned/experimental.rs +++ b/vortex-layout/src/layouts/zoned/skip_index.rs @@ -1,9 +1,4 @@ -//! Experimental skipping-index interface and bloom-filter implementation. -//! -//! This module is intentionally a spike. Its APIs are not stable and its bloom format is not a -//! compatibility commitment. - -mod ngram; +//! Skipping-index interface and Bloom-filter implementation. // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors @@ -15,8 +10,6 @@ use std::num::NonZeroU8; use std::num::NonZeroUsize; use std::sync::Arc; -pub use ngram::NGramBloomOptions; -pub use ngram::NGramBloomSkipIndex; use vortex_array::ArrayRef; use vortex_array::Columnar; use vortex_array::ExecutionCtx; @@ -65,7 +58,7 @@ use super::writer::default_zoned_aggregate_fns; /// needed to consult it. /// /// The writer helper [`ZonedLayoutOptions::with_skip_index`] is the explicit per-column declaration -/// seam. Readers call [`SkipIndex::register`] on their fresh session before opening the file. +/// seam. Readers call [`SkipIndex::register`] on their session before opening the file. pub trait SkipIndex: Debug + Send + Sync + 'static { /// The aggregate state to persist for `input_dtype`, or `None` when unsupported. fn aggregate_fn(&self, input_dtype: &DType) -> Option; @@ -77,8 +70,8 @@ pub trait SkipIndex: Debug + Send + Sync + 'static { impl ZonedLayoutOptions { /// Add `index` to this zoned writer while retaining the default min/max-style aggregates. /// - /// A `ZonedStrategy` configured with these options can be installed for one field with - /// `TableStrategy::with_field_writer`, which is the spike's write-time "index column X" API. + /// `WriteStrategyBuilder::with_field_zoned_options` can install the configured options for one + /// field while retaining the default data layout pipeline. pub fn with_skip_index( mut self, index: &I, @@ -142,7 +135,7 @@ impl Display for BloomOptions { } } -/// Experimental bloom skipping index for `i64` equality predicates. +/// Bloom skipping index for `i64` equality predicates. #[derive(Clone, Debug, Default)] pub struct BloomSkipIndex { options: BloomOptions, @@ -178,13 +171,12 @@ impl SkipIndex for BloomSkipIndex { /// Aggregate that stores one fixed-size bloom bitset as a `Binary` scalar for every zone. #[derive(Clone, Debug)] -pub struct BloomFilter; +struct BloomFilter; /// In-memory bloom accumulator. Only the bitset is persisted. -pub struct BloomPartial { - pub(super) bits: Vec, - pub(super) hashes: u8, - pub(super) gram_size: Option, +struct BloomPartial { + bits: Vec, + hashes: u8, } impl AggregateFnVTable for BloomFilter { @@ -192,7 +184,7 @@ impl AggregateFnVTable for BloomFilter { type Partial = BloomPartial; fn id(&self) -> AggregateFnId { - static ID: CachedId = CachedId::new("vortex.experimental.bloom_filter.i64.v1"); + static ID: CachedId = CachedId::new("vortex.bloom_filter.i64.v1"); *ID } @@ -235,7 +227,6 @@ impl AggregateFnVTable for BloomFilter { Ok(BloomPartial { bits: vec![0; options.bytes.get()], hashes: options.hashes.get(), - gram_size: None, }) } @@ -309,13 +300,13 @@ impl AggregateFnVTable for BloomFilter { /// Probe scalar function: test one `i64` literal against each binary bloom state. #[derive(Clone, Debug)] -pub struct BloomContains; +struct BloomContains; impl ScalarFnVTable for BloomContains { type Options = BloomOptions; fn id(&self) -> ScalarFnId { - static ID: CachedId = CachedId::new("vortex.experimental.bloom_contains.i64.v1"); + static ID: CachedId = CachedId::new("vortex.bloom_contains.i64.v1"); *ID } @@ -407,7 +398,7 @@ impl ScalarFnVTable for BloomContains { zones = valid_zones, definitely_absent_zones = valid_zones - possible_zones, average_fill_ratio = set_bits as f64 / total_bits, - "experimental skip-index probe" + "skip-index probe" ); } Ok(BoolArray::new(BitBuffer::from_iter(possible), validity).into_array()) @@ -424,7 +415,7 @@ impl ScalarFnVTable for BloomContains { /// Equality rewrite that turns a bloom miss into a zone falsifier. #[derive(Clone, Debug)] -pub struct BloomEqRewrite { +struct BloomEqRewrite { options: BloomOptions, } @@ -480,10 +471,6 @@ fn bloom_insert(bits: &mut [u8], value: i64, hashes: u8) { ); } -pub(super) fn bloom_insert_bytes(bits: &mut [u8], value: &[u8], hashes: u8) { - bloom_insert_hash(bits, stable_hash_bytes(value), hashes); -} - fn bloom_insert_hash(bits: &mut [u8], hash: u64, hashes: u8) { for (byte, bit) in bloom_positions(hash, bits.len(), hashes) { bits[byte] |= 1 << bit; @@ -498,10 +485,6 @@ fn bloom_contains(bits: &[u8], value: i64, hashes: u8) -> bool { ) } -pub(super) fn bloom_contains_bytes(bits: &[u8], value: &[u8], hashes: u8) -> bool { - bloom_contains_hash(bits, stable_hash_bytes(value), hashes) -} - fn bloom_contains_hash(bits: &[u8], hash: u64, hashes: u8) -> bool { bloom_positions(hash, bits.len(), hashes).all(|(byte, bit)| bits[byte] & (1 << bit) != 0) } @@ -521,13 +504,6 @@ fn bloom_positions(hash: u64, bytes: usize, hashes: u8) -> impl Iterator u64 { - let hash = value.iter().fold(0xcbf2_9ce4_8422_2325, |hash, byte| { - (hash ^ u64::from(*byte)).wrapping_mul(0x0000_0100_0000_01b3) - }); - splitmix64(hash ^ 0x243f_6a88_85a3_08d3) -} - fn splitmix64(mut value: u64) -> u64 { value = value.wrapping_add(0x9e37_79b9_7f4a_7c15); value = (value ^ (value >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); @@ -535,8 +511,8 @@ fn splitmix64(mut value: u64) -> u64 { value ^ (value >> 31) } -pub(super) fn diagnostics_enabled() -> bool { - std::env::var("VORTEX_EXPERIMENTAL_SKIP_INDEX_DIAGNOSTICS").is_ok_and(|value| value == "1") +fn diagnostics_enabled() -> bool { + std::env::var("VORTEX_SKIP_INDEX_DIAGNOSTICS").is_ok_and(|value| value == "1") } #[cfg(test)] From 4f0e2a0994904250a1a3d3d2df28334cb255e9a7 Mon Sep 17 00:00:00 2001 From: Connor Tsui Date: Thu, 23 Jul 2026 10:53:10 -0400 Subject: [PATCH 11/13] Record Bloom-only benchmark results Signed-off-by: Connor Tsui --- BLOOM_SKIP_INDEX.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/BLOOM_SKIP_INDEX.md b/BLOOM_SKIP_INDEX.md index c754f73ad3e..5d0ef6e5579 100644 --- a/BLOOM_SKIP_INDEX.md +++ b/BLOOM_SKIP_INDEX.md @@ -72,13 +72,15 @@ retains the normal dictionary, compressor, coalescing, and flat-layout stages an candidate to a separate `vortex-file-bloom` directory. The candidate job times unchanged DataFusion/Parquet alongside indexed DataFusion/Vortex so runner-wide movement is visible. -The earlier mixed-index run already isolated the point-lookup behavior: ClickBench q19 improved -from 29.23 ms to 21.42 ms, approximately **1.33x faster after its Parquet control**. One scan proved -12,294 of 12,299 zones absent and kept only five. The full 43-query suite stayed neutral because the -other queries could not use the equality index. - -A Bloom-only controlled rerun is required to replace the mixed-index file-size number and confirm -the cleaned implementation's wall time. +The [Bloom-only controlled run](https://github.com/vortex-data/vortex/actions/runs/30016883432) +improved ClickBench q19 from 26.22 ms to 18.85 ms: **1.39x faster raw** and **1.43x faster after its +Parquet control**. One scan proved 12,294 of 12,299 zones absent and retained five. Across all 43 +queries, Vortex's geomean was 0.914x while the Parquet control was 0.930x, an attributed 1.8% +improvement that the benchmark classified as no clear signal because the environment was too +noisy. The equality Bloom only applies to q19, so a suite-wide improvement is not expected. + +The indexed Vortex files grew from 11.04 GB to 11.13 GB in total, or 0.8%. Each roughly +one-million-row shard added approximately 986 KB. ## Interface recommendation From 69e533de18d4333b51f0e55480fe5b10ae63d8a6 Mon Sep 17 00:00:00 2001 From: Connor Tsui Date: Thu, 23 Jul 2026 11:34:54 -0400 Subject: [PATCH 12/13] Organize zoned skipping indexes and aggregates Signed-off-by: Connor Tsui --- vortex-bench/src/lib.rs | 2 +- vortex-file/tests/bloom_skip_index.rs | 4 +- .../layouts/zoned/aggregates/bloom_filter.rs | 308 +++++++++ .../src/layouts/zoned/aggregates/min_max.rs | 70 +++ .../src/layouts/zoned/aggregates/mod.rs | 80 +++ vortex-layout/src/layouts/zoned/mod.rs | 1 + vortex-layout/src/layouts/zoned/skip_index.rs | 595 ------------------ .../src/layouts/zoned/skip_index/bloom.rs | 294 +++++++++ .../src/layouts/zoned/skip_index/mod.rs | 59 ++ vortex-layout/src/layouts/zoned/writer.rs | 108 +--- 10 files changed, 816 insertions(+), 705 deletions(-) create mode 100644 vortex-layout/src/layouts/zoned/aggregates/bloom_filter.rs create mode 100644 vortex-layout/src/layouts/zoned/aggregates/min_max.rs create mode 100644 vortex-layout/src/layouts/zoned/aggregates/mod.rs delete mode 100644 vortex-layout/src/layouts/zoned/skip_index.rs create mode 100644 vortex-layout/src/layouts/zoned/skip_index/bloom.rs create mode 100644 vortex-layout/src/layouts/zoned/skip_index/mod.rs diff --git a/vortex-bench/src/lib.rs b/vortex-bench/src/lib.rs index 1ce66a925dd..450cd40e3e6 100644 --- a/vortex-bench/src/lib.rs +++ b/vortex-bench/src/lib.rs @@ -72,8 +72,8 @@ pub use output::create_output_writer; use vortex::VortexSessionDefault; pub use vortex::error::vortex_panic; use vortex::io::session::RuntimeSessionExt; -use vortex::layout::layouts::zoned::skip_index::BloomSkipIndex; use vortex::layout::layouts::zoned::skip_index::SkipIndex; +use vortex::layout::layouts::zoned::skip_index::bloom::BloomSkipIndex; use vortex::session::VortexSession; // All benchmarks run with mimalloc for consistency. diff --git a/vortex-file/tests/bloom_skip_index.rs b/vortex-file/tests/bloom_skip_index.rs index 4fdeaf15701..0982e736daf 100644 --- a/vortex-file/tests/bloom_skip_index.rs +++ b/vortex-file/tests/bloom_skip_index.rs @@ -38,9 +38,9 @@ use vortex_file::WriteOptionsSessionExt; use vortex_file::WriteStrategyBuilder; use vortex_io::session::RuntimeSession; use vortex_layout::LayoutStrategy; -use vortex_layout::layouts::zoned::skip_index::BloomOptions; -use vortex_layout::layouts::zoned::skip_index::BloomSkipIndex; use vortex_layout::layouts::zoned::skip_index::SkipIndex; +use vortex_layout::layouts::zoned::skip_index::bloom::BloomOptions; +use vortex_layout::layouts::zoned::skip_index::bloom::BloomSkipIndex; use vortex_layout::layouts::zoned::writer::ZonedLayoutOptions; use vortex_layout::segments::SegmentFuture; use vortex_layout::segments::SegmentId; diff --git a/vortex-layout/src/layouts/zoned/aggregates/bloom_filter.rs b/vortex-layout/src/layouts/zoned/aggregates/bloom_filter.rs new file mode 100644 index 00000000000..d1d3707beab --- /dev/null +++ b/vortex-layout/src/layouts/zoned/aggregates/bloom_filter.rs @@ -0,0 +1,308 @@ +//! Bloom-filter aggregate for zoned layouts. + +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +use std::fmt; +use std::fmt::Display; +use std::fmt::Formatter; +use std::num::NonZeroU8; +use std::num::NonZeroUsize; + +use vortex_array::ArrayRef; +use vortex_array::Columnar; +use vortex_array::ExecutionCtx; +use vortex_array::aggregate_fn::AggregateFnId; +use vortex_array::aggregate_fn::AggregateFnVTable; +use vortex_array::dtype::DType; +use vortex_array::dtype::Nullability; +use vortex_array::dtype::PType; +use vortex_array::scalar::Scalar; +use vortex_error::VortexResult; +use vortex_error::vortex_ensure; +use vortex_error::vortex_err; +use vortex_session::VortexSession; +use vortex_session::registry::CachedId; + +/// Bloom-filter tuning persisted as aggregate metadata. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct BloomOptions { + bytes: NonZeroUsize, + hashes: NonZeroU8, +} + +impl BloomOptions { + /// Create bloom options with a fixed number of bytes and hash probes per zone. + pub fn new(bytes: NonZeroUsize, hashes: NonZeroU8) -> Self { + Self { bytes, hashes } + } + + /// Bytes stored for each zone. + pub fn bytes(&self) -> NonZeroUsize { + self.bytes + } + + /// Hash probes performed for each inserted or tested value. + pub fn hashes(&self) -> NonZeroU8 { + self.hashes + } +} + +impl Default for BloomOptions { + fn default() -> Self { + Self { + // Eight bits per row at the default 8192-row zone size. + bytes: NonZeroUsize::new(8192).unwrap_or(NonZeroUsize::MIN), + hashes: NonZeroU8::new(5).unwrap_or(NonZeroU8::MIN), + } + } +} + +impl Display for BloomOptions { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "bytes={},hashes={}", self.bytes, self.hashes) + } +} + +/// Aggregate that stores one fixed-size Bloom bitset as a `Binary` scalar for every zone. +#[derive(Clone, Debug)] +pub(in crate::layouts::zoned) struct BloomFilter; + +/// In-memory Bloom accumulator. Only the bitset is persisted. +pub(in crate::layouts::zoned) struct BloomPartial { + bits: Vec, + hashes: u8, +} + +impl AggregateFnVTable for BloomFilter { + type Options = BloomOptions; + type Partial = BloomPartial; + + fn id(&self) -> AggregateFnId { + static ID: CachedId = CachedId::new("vortex.bloom_filter.i64.v1"); + *ID + } + + fn serialize(&self, options: &Self::Options) -> VortexResult>> { + let bytes = u32::try_from(options.bytes.get())?; + let mut metadata = bytes.to_le_bytes().to_vec(); + metadata.push(options.hashes.get()); + Ok(Some(metadata)) + } + + fn deserialize( + &self, + metadata: &[u8], + _session: &VortexSession, + ) -> VortexResult { + vortex_ensure!(metadata.len() == 5, "invalid bloom metadata length"); + let bytes = u32::from_le_bytes([metadata[0], metadata[1], metadata[2], metadata[3]]); + Ok(BloomOptions::new( + NonZeroUsize::new(bytes as usize) + .ok_or_else(|| vortex_err!("bloom byte length must be non-zero"))?, + NonZeroU8::new(metadata[4]) + .ok_or_else(|| vortex_err!("bloom hash count must be non-zero"))?, + )) + } + + fn return_dtype(&self, _options: &Self::Options, input_dtype: &DType) -> Option { + matches!(input_dtype, DType::Primitive(PType::I64, _)) + .then_some(DType::Binary(Nullability::NonNullable)) + } + + fn partial_dtype(&self, options: &Self::Options, input_dtype: &DType) -> Option { + self.return_dtype(options, input_dtype) + } + + fn empty_partial( + &self, + options: &Self::Options, + _input_dtype: &DType, + ) -> VortexResult { + Ok(BloomPartial { + bits: vec![0; options.bytes.get()], + hashes: options.hashes.get(), + }) + } + + fn combine_partials(&self, partial: &mut Self::Partial, other: Scalar) -> VortexResult<()> { + if other.is_null() { + return Ok(()); + } + let other = other + .as_binary() + .value() + .ok_or_else(|| vortex_err!("non-null bloom partial has no bytes"))?; + vortex_ensure!( + partial.bits.len() == other.len(), + "bloom partial length mismatch" + ); + for (dst, src) in partial.bits.iter_mut().zip(other.as_slice()) { + *dst |= *src; + } + Ok(()) + } + + fn to_scalar(&self, partial: &Self::Partial) -> VortexResult { + Ok(Scalar::binary( + partial.bits.clone(), + Nullability::NonNullable, + )) + } + + fn reset(&self, partial: &mut Self::Partial) { + partial.bits.fill(0); + } + + fn is_saturated(&self, partial: &Self::Partial) -> bool { + partial.bits.iter().all(|byte| *byte == u8::MAX) + } + + fn accumulate( + &self, + partial: &mut Self::Partial, + batch: &Columnar, + ctx: &mut ExecutionCtx, + ) -> VortexResult<()> { + match batch { + Columnar::Constant(constant) => { + if let Some(value) = i64_value(constant.scalar())? { + bloom_insert(&mut partial.bits, value, partial.hashes); + } + } + Columnar::Canonical(canonical) => { + let primitive = canonical.as_primitive(); + let values = primitive.as_slice::(); + let validity = primitive.validity()?.execute_mask(values.len(), ctx)?; + for (&value, valid) in values.iter().zip(validity.iter()) { + if valid { + bloom_insert(&mut partial.bits, value, partial.hashes); + } + } + } + } + Ok(()) + } + + fn finalize(&self, partials: ArrayRef) -> VortexResult { + Ok(partials) + } + + fn finalize_scalar(&self, partial: &Self::Partial) -> VortexResult { + self.to_scalar(partial) + } +} + +pub(in crate::layouts::zoned) fn i64_value(scalar: &Scalar) -> VortexResult> { + if scalar.is_null() { + return Ok(None); + } + scalar + .as_primitive_opt() + .and_then(|primitive| primitive.typed_value::()) + .map(Some) + .ok_or_else(|| vortex_err!("bloom value must be i64")) +} + +fn bloom_insert(bits: &mut [u8], value: i64, hashes: u8) { + bloom_insert_hash( + bits, + splitmix64(value as u64 ^ 0x243f_6a88_85a3_08d3), + hashes, + ); +} + +fn bloom_insert_hash(bits: &mut [u8], hash: u64, hashes: u8) { + for (byte, bit) in bloom_positions(hash, bits.len(), hashes) { + bits[byte] |= 1 << bit; + } +} + +pub(in crate::layouts::zoned) fn bloom_contains(bits: &[u8], value: i64, hashes: u8) -> bool { + bloom_contains_hash( + bits, + splitmix64(value as u64 ^ 0x243f_6a88_85a3_08d3), + hashes, + ) +} + +fn bloom_contains_hash(bits: &[u8], hash: u64, hashes: u8) -> bool { + bloom_positions(hash, bits.len(), hashes).all(|(byte, bit)| bits[byte] & (1 << bit) != 0) +} + +fn bloom_positions(hash: u64, bytes: usize, hashes: u8) -> impl Iterator { + let h1 = hash; + let h2 = splitmix64(h1 ^ 0x1319_8a2e_0370_7344) | 1; + let bit_len = u64::try_from(bytes).unwrap_or(u64::MAX).saturating_mul(8); + (0..u64::from(hashes)).map(move |probe| { + let position = h1 + .wrapping_add(probe.wrapping_mul(h2)) + .wrapping_rem(bit_len); + // `position / 8` is less than `bytes`, which is already a `usize`. + let byte = usize::try_from(position / 8).unwrap_or_default(); + let bit = u32::try_from(position % 8).unwrap_or_default(); + (byte, bit) + }) +} + +fn splitmix64(mut value: u64) -> u64 { + value = value.wrapping_add(0x9e37_79b9_7f4a_7c15); + value = (value ^ (value >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + value = (value ^ (value >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + value ^ (value >> 31) +} + +#[cfg(test)] +mod tests { + use std::num::NonZeroU8; + use std::num::NonZeroUsize; + + use vortex_array::IntoArray; + use vortex_array::VortexSessionExecute; + use vortex_array::aggregate_fn::Accumulator; + use vortex_array::aggregate_fn::AggregateFnVTable; + use vortex_array::aggregate_fn::DynAccumulator; + use vortex_array::arrays::PrimitiveArray; + use vortex_array::dtype::DType; + use vortex_array::dtype::Nullability; + use vortex_array::dtype::PType; + use vortex_error::VortexResult; + + use super::BloomFilter; + use super::BloomOptions; + use super::bloom_contains; + + fn small_options() -> BloomOptions { + BloomOptions::new( + NonZeroUsize::new(64).expect("64 is non-zero"), + NonZeroU8::new(3).expect("3 is non-zero"), + ) + } + + #[test] + fn roundtrips_options_and_membership() -> VortexResult<()> { + let session = vortex_array::array_session(); + let options = small_options(); + let metadata = BloomFilter + .serialize(&options)? + .expect("bloom is serializable"); + assert_eq!(BloomFilter.deserialize(&metadata, &session)?, options); + + let mut ctx = session.create_execution_ctx(); + let mut accumulator = Accumulator::try_new( + BloomFilter, + options.clone(), + DType::Primitive(PType::I64, Nullability::NonNullable), + )?; + accumulator.accumulate( + &PrimitiveArray::from_iter([10i64, 20, 30]).into_array(), + &mut ctx, + )?; + let state = accumulator.finish()?; + let bytes = state.as_binary().value().expect("bloom state is non-null"); + assert!(bloom_contains(bytes.as_slice(), 10, options.hashes.get())); + assert!(bloom_contains(bytes.as_slice(), 20, options.hashes.get())); + assert!(!bloom_contains(bytes.as_slice(), 999, options.hashes.get())); + Ok(()) + } +} diff --git a/vortex-layout/src/layouts/zoned/aggregates/min_max.rs b/vortex-layout/src/layouts/zoned/aggregates/min_max.rs new file mode 100644 index 00000000000..0403d5fb404 --- /dev/null +++ b/vortex-layout/src/layouts/zoned/aggregates/min_max.rs @@ -0,0 +1,70 @@ +//! Min/max aggregate selection for zoned layouts. + +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +use vortex_array::aggregate_fn::AggregateFnRef; +use vortex_array::aggregate_fn::AggregateFnVTableExt; +use vortex_array::aggregate_fn::NumericalAggregateOpts; +use vortex_array::aggregate_fn::fns::bounded_max::BoundedMax; +use vortex_array::aggregate_fn::fns::bounded_max::BoundedMaxOptions; +use vortex_array::aggregate_fn::fns::bounded_min::BoundedMin; +use vortex_array::aggregate_fn::fns::bounded_min::BoundedMinOptions; +use vortex_array::aggregate_fn::fns::max::Max; +use vortex_array::aggregate_fn::fns::min::Min; +use vortex_array::dtype::DType; + +use super::super::schema::default_bounded_stat_max_bytes; + +pub(super) fn min_max_aggregate_fns(dtype: &DType) -> [AggregateFnRef; 2] { + match dtype { + DType::Utf8(_) | DType::Binary(_) => [ + BoundedMax.bind(BoundedMaxOptions { + max_bytes: default_bounded_stat_max_bytes(), + }), + BoundedMin.bind(BoundedMinOptions { + max_bytes: default_bounded_stat_max_bytes(), + }), + ], + _ => [ + Max.bind(NumericalAggregateOpts::skip_nans()), + Min.bind(NumericalAggregateOpts::skip_nans()), + ], + } +} + +#[cfg(test)] +mod tests { + use vortex_array::aggregate_fn::fns::bounded_max::BoundedMax; + use vortex_array::aggregate_fn::fns::bounded_min::BoundedMin; + use vortex_array::aggregate_fn::fns::max::Max; + use vortex_array::aggregate_fn::fns::min::Min; + use vortex_array::dtype::DType; + use vortex_array::dtype::Nullability; + use vortex_array::dtype::PType; + + use super::default_bounded_stat_max_bytes; + use super::min_max_aggregate_fns; + + #[test] + fn variable_length_min_max_are_bounded() { + let aggregate_fns = min_max_aggregate_fns(&DType::Utf8(Nullability::NonNullable)); + + assert_eq!( + aggregate_fns[0].as_::().max_bytes, + default_bounded_stat_max_bytes() + ); + assert_eq!( + aggregate_fns[1].as_::().max_bytes, + default_bounded_stat_max_bytes() + ); + } + + #[test] + fn fixed_width_min_max_are_exact() { + let aggregate_fns = min_max_aggregate_fns(&PType::I32.into()); + + assert!(aggregate_fns[0].is::()); + assert!(aggregate_fns[1].is::()); + } +} diff --git a/vortex-layout/src/layouts/zoned/aggregates/mod.rs b/vortex-layout/src/layouts/zoned/aggregates/mod.rs new file mode 100644 index 00000000000..19c975bac2b --- /dev/null +++ b/vortex-layout/src/layouts/zoned/aggregates/mod.rs @@ -0,0 +1,80 @@ +//! Aggregate functions selected by the zoned layout. + +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +use std::sync::Arc; + +use vortex_array::aggregate_fn::AggregateFnRef; +use vortex_array::aggregate_fn::AggregateFnVTable; +use vortex_array::aggregate_fn::AggregateFnVTableExt; +use vortex_array::aggregate_fn::EmptyOptions; +use vortex_array::aggregate_fn::NumericalAggregateOpts; +use vortex_array::aggregate_fn::fns::nan_count::NanCount; +use vortex_array::aggregate_fn::fns::null_count::NullCount; +use vortex_array::aggregate_fn::fns::sum::Sum; +use vortex_array::aggregate_fn::session::AggregateFnSessionExt; +use vortex_array::dtype::DType; +use vortex_session::VortexSession; + +pub(in crate::layouts::zoned) mod bloom_filter; +mod min_max; + +pub(in crate::layouts::zoned) use bloom_filter::BloomFilter; +pub(in crate::layouts::zoned) use bloom_filter::bloom_contains; +pub(in crate::layouts::zoned) use bloom_filter::i64_value; +use min_max::min_max_aggregate_fns; + +pub(super) fn default_zoned_aggregate_fns( + dtype: &DType, + session: &VortexSession, +) -> Arc<[AggregateFnRef]> { + let mut aggregate_fns = Vec::from(min_max_aggregate_fns(dtype)); + if Sum + .return_dtype(&NumericalAggregateOpts::skip_nans(), dtype) + .is_some() + { + aggregate_fns.push(Sum.bind(NumericalAggregateOpts::skip_nans())); + } + aggregate_fns.push(NanCount.bind(EmptyOptions)); + aggregate_fns.push(NullCount.bind(EmptyOptions)); + + // Stats from geo extension types are discovered from the registry at runtime instead. + aggregate_fns.extend(session.aggregate_fns().zone_stat_defaults(dtype)); + + aggregate_fns.into() +} + +#[cfg(test)] +mod tests { + use vortex_array::aggregate_fn::fns::sum::Sum; + use vortex_array::dtype::DType; + use vortex_array::dtype::Nullability; + use vortex_array::dtype::PType; + use vortex_array::extension::datetime::TimeUnit; + use vortex_array::extension::datetime::Timestamp; + + use super::default_zoned_aggregate_fns; + + #[test] + fn default_aggregates_include_sum_for_numeric_dtype() { + let aggregate_fns = + default_zoned_aggregate_fns(&PType::I32.into(), &vortex_array::array_session()); + + assert!(aggregate_fns[2].is::()); + } + + #[test] + fn default_aggregates_skip_sum_for_non_summable_dtype() { + let dtype = DType::Extension( + Timestamp::new(TimeUnit::Microseconds, Nullability::Nullable).erased(), + ); + let aggregate_fns = default_zoned_aggregate_fns(&dtype, &vortex_array::array_session()); + + assert!( + aggregate_fns + .iter() + .all(|aggregate_fn| !aggregate_fn.is::()) + ); + } +} diff --git a/vortex-layout/src/layouts/zoned/mod.rs b/vortex-layout/src/layouts/zoned/mod.rs index 72e4b67bbb5..a877c36e5f6 100644 --- a/vortex-layout/src/layouts/zoned/mod.rs +++ b/vortex-layout/src/layouts/zoned/mod.rs @@ -11,6 +11,7 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright the Vortex contributors +mod aggregates; mod builder; mod pruning; mod reader; diff --git a/vortex-layout/src/layouts/zoned/skip_index.rs b/vortex-layout/src/layouts/zoned/skip_index.rs deleted file mode 100644 index 3e5c6958553..00000000000 --- a/vortex-layout/src/layouts/zoned/skip_index.rs +++ /dev/null @@ -1,595 +0,0 @@ -//! Skipping-index interface and Bloom-filter implementation. - -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright the Vortex contributors -use std::fmt; -use std::fmt::Debug; -use std::fmt::Display; -use std::fmt::Formatter; -use std::num::NonZeroU8; -use std::num::NonZeroUsize; -use std::sync::Arc; - -use vortex_array::ArrayRef; -use vortex_array::Columnar; -use vortex_array::ExecutionCtx; -use vortex_array::IntoArray; -use vortex_array::aggregate_fn::AggregateFnId; -use vortex_array::aggregate_fn::AggregateFnRef; -use vortex_array::aggregate_fn::AggregateFnVTable; -use vortex_array::aggregate_fn::AggregateFnVTableExt; -use vortex_array::aggregate_fn::session::AggregateFnSessionExt; -use vortex_array::arrays::BoolArray; -use vortex_array::arrays::ConstantArray; -use vortex_array::arrays::VarBinViewArray; -use vortex_array::arrays::varbinview::VarBinViewArrayExt; -use vortex_array::dtype::DType; -use vortex_array::dtype::Nullability; -use vortex_array::dtype::PType; -use vortex_array::expr::Expression; -use vortex_array::expr::is_root; -use vortex_array::expr::not; -use vortex_array::scalar::Scalar; -use vortex_array::scalar_fn::Arity; -use vortex_array::scalar_fn::ChildName; -use vortex_array::scalar_fn::ExecutionArgs; -use vortex_array::scalar_fn::ScalarFnId; -use vortex_array::scalar_fn::ScalarFnVTable; -use vortex_array::scalar_fn::ScalarFnVTableExt; -use vortex_array::scalar_fn::fns::binary::Binary; -use vortex_array::scalar_fn::fns::literal::Literal; -use vortex_array::scalar_fn::fns::operators::Operator; -use vortex_array::scalar_fn::session::ScalarFnSessionExt; -use vortex_array::stats::rewrite::StatsRewriteCtx; -use vortex_array::stats::rewrite::StatsRewriteRule; -use vortex_array::stats::session::StatsSessionExt; -use vortex_array::stats::stat; -use vortex_buffer::BitBuffer; -use vortex_error::VortexResult; -use vortex_error::vortex_ensure; -use vortex_error::vortex_err; -use vortex_session::VortexSession; -use vortex_session::registry::CachedId; - -use super::writer::ZonedLayoutOptions; -use super::writer::default_zoned_aggregate_fns; - -/// One definition that supplies a persisted aggregate and registers every read-side component -/// needed to consult it. -/// -/// The writer helper [`ZonedLayoutOptions::with_skip_index`] is the explicit per-column declaration -/// seam. Readers call [`SkipIndex::register`] on their session before opening the file. -pub trait SkipIndex: Debug + Send + Sync + 'static { - /// The aggregate state to persist for `input_dtype`, or `None` when unsupported. - fn aggregate_fn(&self, input_dtype: &DType) -> Option; - - /// Register the aggregate, optional probe function, and predicate rewrite as one operation. - fn register(&self, session: &VortexSession); -} - -impl ZonedLayoutOptions { - /// Add `index` to this zoned writer while retaining the default min/max-style aggregates. - /// - /// `WriteStrategyBuilder::with_field_zoned_options` can install the configured options for one - /// field while retaining the default data layout pipeline. - pub fn with_skip_index( - mut self, - index: &I, - input_dtype: &DType, - session: &VortexSession, - ) -> VortexResult { - let aggregate_fn = index - .aggregate_fn(input_dtype) - .ok_or_else(|| vortex_err!("skip index does not support input dtype {input_dtype}"))?; - - let mut aggregate_fns = self - .aggregate_fns - .take() - .unwrap_or_else(|| default_zoned_aggregate_fns(input_dtype, session)) - .to_vec(); - if !aggregate_fns.iter().any(|stored| stored == &aggregate_fn) { - aggregate_fns.push(aggregate_fn); - } - self.aggregate_fns = Some(Arc::from(aggregate_fns)); - Ok(self) - } -} - -/// Bloom-filter tuning persisted as aggregate metadata. -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub struct BloomOptions { - bytes: NonZeroUsize, - hashes: NonZeroU8, -} - -impl BloomOptions { - /// Create bloom options with a fixed number of bytes and hash probes per zone. - pub fn new(bytes: NonZeroUsize, hashes: NonZeroU8) -> Self { - Self { bytes, hashes } - } - - /// Bytes stored for each zone. - pub fn bytes(&self) -> NonZeroUsize { - self.bytes - } - - /// Hash probes performed for each inserted or tested value. - pub fn hashes(&self) -> NonZeroU8 { - self.hashes - } -} - -impl Default for BloomOptions { - fn default() -> Self { - Self { - // Eight bits per row at the default 8192-row zone size. - bytes: NonZeroUsize::new(8192).unwrap_or(NonZeroUsize::MIN), - hashes: NonZeroU8::new(5).unwrap_or(NonZeroU8::MIN), - } - } -} - -impl Display for BloomOptions { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - write!(f, "bytes={},hashes={}", self.bytes, self.hashes) - } -} - -/// Bloom skipping index for `i64` equality predicates. -#[derive(Clone, Debug, Default)] -pub struct BloomSkipIndex { - options: BloomOptions, -} - -impl BloomSkipIndex { - /// Create an index with explicit bloom tuning. - pub fn new(options: BloomOptions) -> Self { - Self { options } - } - - /// The persisted bloom options. - pub fn options(&self) -> &BloomOptions { - &self.options - } -} - -impl SkipIndex for BloomSkipIndex { - fn aggregate_fn(&self, input_dtype: &DType) -> Option { - BloomFilter - .return_dtype(&self.options, input_dtype) - .map(|_| BloomFilter.bind(self.options.clone())) - } - - fn register(&self, session: &VortexSession) { - session.aggregate_fns().register(BloomFilter); - session.scalar_fns().register(BloomContains); - session.stats().register_rewrite(BloomEqRewrite { - options: self.options.clone(), - }); - } -} - -/// Aggregate that stores one fixed-size bloom bitset as a `Binary` scalar for every zone. -#[derive(Clone, Debug)] -struct BloomFilter; - -/// In-memory bloom accumulator. Only the bitset is persisted. -struct BloomPartial { - bits: Vec, - hashes: u8, -} - -impl AggregateFnVTable for BloomFilter { - type Options = BloomOptions; - type Partial = BloomPartial; - - fn id(&self) -> AggregateFnId { - static ID: CachedId = CachedId::new("vortex.bloom_filter.i64.v1"); - *ID - } - - fn serialize(&self, options: &Self::Options) -> VortexResult>> { - let bytes = u32::try_from(options.bytes.get())?; - let mut metadata = bytes.to_le_bytes().to_vec(); - metadata.push(options.hashes.get()); - Ok(Some(metadata)) - } - - fn deserialize( - &self, - metadata: &[u8], - _session: &VortexSession, - ) -> VortexResult { - vortex_ensure!(metadata.len() == 5, "invalid bloom metadata length"); - let bytes = u32::from_le_bytes([metadata[0], metadata[1], metadata[2], metadata[3]]); - Ok(BloomOptions::new( - NonZeroUsize::new(bytes as usize) - .ok_or_else(|| vortex_err!("bloom byte length must be non-zero"))?, - NonZeroU8::new(metadata[4]) - .ok_or_else(|| vortex_err!("bloom hash count must be non-zero"))?, - )) - } - - fn return_dtype(&self, _options: &Self::Options, input_dtype: &DType) -> Option { - matches!(input_dtype, DType::Primitive(PType::I64, _)) - .then_some(DType::Binary(Nullability::NonNullable)) - } - - fn partial_dtype(&self, options: &Self::Options, input_dtype: &DType) -> Option { - self.return_dtype(options, input_dtype) - } - - fn empty_partial( - &self, - options: &Self::Options, - _input_dtype: &DType, - ) -> VortexResult { - Ok(BloomPartial { - bits: vec![0; options.bytes.get()], - hashes: options.hashes.get(), - }) - } - - fn combine_partials(&self, partial: &mut Self::Partial, other: Scalar) -> VortexResult<()> { - if other.is_null() { - return Ok(()); - } - let other = other - .as_binary() - .value() - .ok_or_else(|| vortex_err!("non-null bloom partial has no bytes"))?; - vortex_ensure!( - partial.bits.len() == other.len(), - "bloom partial length mismatch" - ); - for (dst, src) in partial.bits.iter_mut().zip(other.as_slice()) { - *dst |= *src; - } - Ok(()) - } - - fn to_scalar(&self, partial: &Self::Partial) -> VortexResult { - Ok(Scalar::binary( - partial.bits.clone(), - Nullability::NonNullable, - )) - } - - fn reset(&self, partial: &mut Self::Partial) { - partial.bits.fill(0); - } - - fn is_saturated(&self, partial: &Self::Partial) -> bool { - partial.bits.iter().all(|byte| *byte == u8::MAX) - } - - fn accumulate( - &self, - partial: &mut Self::Partial, - batch: &Columnar, - ctx: &mut ExecutionCtx, - ) -> VortexResult<()> { - match batch { - Columnar::Constant(constant) => { - if let Some(value) = i64_value(constant.scalar())? { - bloom_insert(&mut partial.bits, value, partial.hashes); - } - } - Columnar::Canonical(canonical) => { - let primitive = canonical.as_primitive(); - let values = primitive.as_slice::(); - let validity = primitive.validity()?.execute_mask(values.len(), ctx)?; - for (&value, valid) in values.iter().zip(validity.iter()) { - if valid { - bloom_insert(&mut partial.bits, value, partial.hashes); - } - } - } - } - Ok(()) - } - - fn finalize(&self, partials: ArrayRef) -> VortexResult { - Ok(partials) - } - - fn finalize_scalar(&self, partial: &Self::Partial) -> VortexResult { - self.to_scalar(partial) - } -} - -/// Probe scalar function: test one `i64` literal against each binary bloom state. -#[derive(Clone, Debug)] -struct BloomContains; - -impl ScalarFnVTable for BloomContains { - type Options = BloomOptions; - - fn id(&self) -> ScalarFnId { - static ID: CachedId = CachedId::new("vortex.bloom_contains.i64.v1"); - *ID - } - - fn serialize(&self, options: &Self::Options) -> VortexResult>> { - BloomFilter.serialize(options) - } - - fn deserialize(&self, metadata: &[u8], session: &VortexSession) -> VortexResult { - BloomFilter.deserialize(metadata, session) - } - - fn arity(&self, _options: &Self::Options) -> Arity { - Arity::Exact(2) - } - - fn child_name(&self, _options: &Self::Options, child_idx: usize) -> ChildName { - match child_idx { - 0 => ChildName::from("filter"), - 1 => ChildName::from("needle"), - _ => unreachable!("bloom_contains has exactly two children"), - } - } - - fn return_dtype(&self, _options: &Self::Options, args: &[DType]) -> VortexResult { - vortex_ensure!( - matches!(args[0], DType::Binary(_)), - "bloom filter must be Binary" - ); - vortex_ensure!( - matches!(args[1], DType::Primitive(PType::I64, _)), - "bloom needle must be i64" - ); - Ok(DType::Bool(args[0].nullability() | args[1].nullability())) - } - - fn execute( - &self, - options: &Self::Options, - args: &dyn ExecutionArgs, - ctx: &mut ExecutionCtx, - ) -> VortexResult { - let filters = args.get(0)?.execute::(ctx)?; - let needle_array = args.get(1)?; - let needle = needle_array - .as_constant() - .ok_or_else(|| vortex_err!("bloom needle must be constant"))?; - let Some(needle) = i64_value(&needle)? else { - return Ok(ConstantArray::new( - Scalar::null(DType::Bool(Nullability::Nullable)), - args.row_count(), - ) - .into_array()); - }; - - let validity = filters.varbinview_validity(); - let valid = validity.execute_mask(filters.len(), ctx)?; - let mut possible = Vec::with_capacity(filters.len()); - let mut set_bits = 0u64; - let mut valid_zones = 0usize; - for (idx, is_valid) in valid.iter().enumerate() { - if is_valid { - let filter = filters.bytes_at(idx); - vortex_ensure!( - filter.len() == options.bytes.get(), - "stored bloom byte length does not match options" - ); - set_bits += filter - .as_slice() - .iter() - .map(|byte| u64::from(byte.count_ones())) - .sum::(); - valid_zones += 1; - possible.push(bloom_contains( - filter.as_slice(), - needle, - options.hashes.get(), - )); - } else { - possible.push(false); - } - } - if diagnostics_enabled() && valid_zones > 0 { - let total_bits = valid_zones as f64 * options.bytes.get() as f64 * 8.0; - let possible_zones = possible.iter().filter(|value| **value).count(); - tracing::info!( - target: "vortex_layout::skip_index", - index = "bloom", - needle, - zones = valid_zones, - definitely_absent_zones = valid_zones - possible_zones, - average_fill_ratio = set_bits as f64 / total_bits, - "skip-index probe" - ); - } - Ok(BoolArray::new(BitBuffer::from_iter(possible), validity).into_array()) - } - - fn is_null_sensitive(&self, _options: &Self::Options) -> bool { - false - } - - fn is_fallible(&self, _options: &Self::Options) -> bool { - false - } -} - -/// Equality rewrite that turns a bloom miss into a zone falsifier. -#[derive(Clone, Debug)] -struct BloomEqRewrite { - options: BloomOptions, -} - -impl StatsRewriteRule for BloomEqRewrite { - fn scalar_fn_id(&self) -> ScalarFnId { - Binary.id() - } - - fn falsify( - &self, - expr: &Expression, - ctx: &StatsRewriteCtx<'_>, - ) -> VortexResult> { - if *expr.as_::() != Operator::Eq { - return Ok(None); - } - - let (column, literal) = if is_root(expr.child(0)) && expr.child(1).is::() { - (expr.child(0), expr.child(1)) - } else if is_root(expr.child(1)) && expr.child(0).is::() { - (expr.child(1), expr.child(0)) - } else { - return Ok(None); - }; - if !matches!(ctx.return_dtype(column)?, DType::Primitive(PType::I64, _)) - || literal.as_::().is_null() - { - return Ok(None); - } - - let filter = stat(column.clone(), BloomFilter.bind(self.options.clone())); - let contains = BloomContains.new_expr(self.options.clone(), [filter, literal.clone()]); - Ok(Some(not(contains))) - } -} - -fn i64_value(scalar: &Scalar) -> VortexResult> { - if scalar.is_null() { - return Ok(None); - } - scalar - .as_primitive_opt() - .and_then(|primitive| primitive.typed_value::()) - .map(Some) - .ok_or_else(|| vortex_err!("bloom value must be i64")) -} - -fn bloom_insert(bits: &mut [u8], value: i64, hashes: u8) { - bloom_insert_hash( - bits, - splitmix64(value as u64 ^ 0x243f_6a88_85a3_08d3), - hashes, - ); -} - -fn bloom_insert_hash(bits: &mut [u8], hash: u64, hashes: u8) { - for (byte, bit) in bloom_positions(hash, bits.len(), hashes) { - bits[byte] |= 1 << bit; - } -} - -fn bloom_contains(bits: &[u8], value: i64, hashes: u8) -> bool { - bloom_contains_hash( - bits, - splitmix64(value as u64 ^ 0x243f_6a88_85a3_08d3), - hashes, - ) -} - -fn bloom_contains_hash(bits: &[u8], hash: u64, hashes: u8) -> bool { - bloom_positions(hash, bits.len(), hashes).all(|(byte, bit)| bits[byte] & (1 << bit) != 0) -} - -fn bloom_positions(hash: u64, bytes: usize, hashes: u8) -> impl Iterator { - let h1 = hash; - let h2 = splitmix64(h1 ^ 0x1319_8a2e_0370_7344) | 1; - let bit_len = u64::try_from(bytes).unwrap_or(u64::MAX).saturating_mul(8); - (0..u64::from(hashes)).map(move |probe| { - let position = h1 - .wrapping_add(probe.wrapping_mul(h2)) - .wrapping_rem(bit_len); - // `position / 8` is less than `bytes`, which is already a `usize`. - let byte = usize::try_from(position / 8).unwrap_or_default(); - let bit = u32::try_from(position % 8).unwrap_or_default(); - (byte, bit) - }) -} - -fn splitmix64(mut value: u64) -> u64 { - value = value.wrapping_add(0x9e37_79b9_7f4a_7c15); - value = (value ^ (value >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); - value = (value ^ (value >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); - value ^ (value >> 31) -} - -fn diagnostics_enabled() -> bool { - std::env::var("VORTEX_SKIP_INDEX_DIAGNOSTICS").is_ok_and(|value| value == "1") -} - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use vortex_array::IntoArray; - use vortex_array::VortexSessionExecute; - use vortex_array::aggregate_fn::Accumulator; - use vortex_array::aggregate_fn::DynAccumulator; - use vortex_array::arrays::PrimitiveArray; - use vortex_array::arrays::StructArray; - use vortex_array::dtype::Nullability; - use vortex_array::expr::eq; - use vortex_array::expr::lit; - use vortex_array::expr::root; - use vortex_array::validity::Validity; - use vortex_error::VortexResult; - - use super::*; - use crate::layouts::zoned::zone_map::ZoneMap; - - fn small_options() -> BloomOptions { - BloomOptions::new( - NonZeroUsize::new(64).expect("64 is non-zero"), - NonZeroU8::new(3).expect("3 is non-zero"), - ) - } - - #[test] - fn aggregate_roundtrips_options_and_membership() -> VortexResult<()> { - let session = vortex_array::array_session(); - let options = small_options(); - let metadata = BloomFilter - .serialize(&options)? - .expect("bloom is serializable"); - assert_eq!(BloomFilter.deserialize(&metadata, &session)?, options); - - let mut ctx = session.create_execution_ctx(); - let mut accumulator = Accumulator::try_new( - BloomFilter, - options.clone(), - DType::Primitive(PType::I64, Nullability::NonNullable), - )?; - accumulator.accumulate( - &PrimitiveArray::from_iter([10i64, 20, 30]).into_array(), - &mut ctx, - )?; - let state = accumulator.finish()?; - let bytes = state.as_binary().value().expect("bloom state is non-null"); - assert!(bloom_contains(bytes.as_slice(), 10, options.hashes.get())); - assert!(bloom_contains(bytes.as_slice(), 20, options.hashes.get())); - assert!(!bloom_contains(bytes.as_slice(), 999, options.hashes.get())); - Ok(()) - } - - #[test] - fn missing_stat_stays_inconclusive() -> VortexResult<()> { - let session = vortex_array::array_session(); - let index = BloomSkipIndex::new(small_options()); - index.register(&session); - let predicate = eq(root(), lit(42i64)); - let proof = predicate - .falsify( - &DType::Primitive(PType::I64, Nullability::NonNullable), - &session, - )? - .expect("equality has a bloom proof"); - - let zone_map = ZoneMap::try_new( - DType::Primitive(PType::I64, Nullability::NonNullable), - StructArray::try_new(Vec::<&str>::new().into(), vec![], 2, Validity::NonNullable)?, - Arc::new([]), - 8, - 16, - )?; - assert!(zone_map.prune(&proof, &session)?.all_false()); - Ok(()) - } -} diff --git a/vortex-layout/src/layouts/zoned/skip_index/bloom.rs b/vortex-layout/src/layouts/zoned/skip_index/bloom.rs new file mode 100644 index 00000000000..74906c2b6ca --- /dev/null +++ b/vortex-layout/src/layouts/zoned/skip_index/bloom.rs @@ -0,0 +1,294 @@ +//! Bloom skipping index for equality predicates. + +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +use vortex_array::ArrayRef; +use vortex_array::ExecutionCtx; +use vortex_array::IntoArray; +use vortex_array::aggregate_fn::AggregateFnRef; +use vortex_array::aggregate_fn::AggregateFnVTable; +use vortex_array::aggregate_fn::AggregateFnVTableExt; +use vortex_array::aggregate_fn::session::AggregateFnSessionExt; +use vortex_array::arrays::BoolArray; +use vortex_array::arrays::ConstantArray; +use vortex_array::arrays::VarBinViewArray; +use vortex_array::arrays::varbinview::VarBinViewArrayExt; +use vortex_array::dtype::DType; +use vortex_array::dtype::Nullability; +use vortex_array::dtype::PType; +use vortex_array::expr::Expression; +use vortex_array::expr::is_root; +use vortex_array::expr::not; +use vortex_array::scalar::Scalar; +use vortex_array::scalar_fn::Arity; +use vortex_array::scalar_fn::ChildName; +use vortex_array::scalar_fn::ExecutionArgs; +use vortex_array::scalar_fn::ScalarFnId; +use vortex_array::scalar_fn::ScalarFnVTable; +use vortex_array::scalar_fn::ScalarFnVTableExt; +use vortex_array::scalar_fn::fns::binary::Binary; +use vortex_array::scalar_fn::fns::literal::Literal; +use vortex_array::scalar_fn::fns::operators::Operator; +use vortex_array::scalar_fn::session::ScalarFnSessionExt; +use vortex_array::stats::rewrite::StatsRewriteCtx; +use vortex_array::stats::rewrite::StatsRewriteRule; +use vortex_array::stats::session::StatsSessionExt; +use vortex_array::stats::stat; +use vortex_buffer::BitBuffer; +use vortex_error::VortexResult; +use vortex_error::vortex_ensure; +use vortex_error::vortex_err; +use vortex_session::VortexSession; +use vortex_session::registry::CachedId; + +use super::super::aggregates::BloomFilter; +use super::super::aggregates::bloom_contains; +pub use super::super::aggregates::bloom_filter::BloomOptions; +use super::super::aggregates::i64_value; +use super::SkipIndex; + +/// Bloom skipping index for `i64` equality predicates. +#[derive(Clone, Debug, Default)] +pub struct BloomSkipIndex { + options: BloomOptions, +} + +impl BloomSkipIndex { + /// Create an index with explicit Bloom tuning. + pub fn new(options: BloomOptions) -> Self { + Self { options } + } + + /// The persisted Bloom options. + pub fn options(&self) -> &BloomOptions { + &self.options + } +} + +impl SkipIndex for BloomSkipIndex { + fn aggregate_fn(&self, input_dtype: &DType) -> Option { + BloomFilter + .return_dtype(&self.options, input_dtype) + .map(|_| BloomFilter.bind(self.options.clone())) + } + + fn register(&self, session: &VortexSession) { + session.aggregate_fns().register(BloomFilter); + session.scalar_fns().register(BloomContains); + session.stats().register_rewrite(BloomEqRewrite { + options: self.options.clone(), + }); + } +} + +/// Probe scalar function: test one `i64` literal against each binary Bloom state. +#[derive(Clone, Debug)] +struct BloomContains; + +impl ScalarFnVTable for BloomContains { + type Options = BloomOptions; + + fn id(&self) -> ScalarFnId { + static ID: CachedId = CachedId::new("vortex.bloom_contains.i64.v1"); + *ID + } + + fn serialize(&self, options: &Self::Options) -> VortexResult>> { + BloomFilter.serialize(options) + } + + fn deserialize(&self, metadata: &[u8], session: &VortexSession) -> VortexResult { + BloomFilter.deserialize(metadata, session) + } + + fn arity(&self, _options: &Self::Options) -> Arity { + Arity::Exact(2) + } + + fn child_name(&self, _options: &Self::Options, child_idx: usize) -> ChildName { + match child_idx { + 0 => ChildName::from("filter"), + 1 => ChildName::from("needle"), + _ => unreachable!("bloom_contains has exactly two children"), + } + } + + fn return_dtype(&self, _options: &Self::Options, args: &[DType]) -> VortexResult { + vortex_ensure!( + matches!(args[0], DType::Binary(_)), + "bloom filter must be Binary" + ); + vortex_ensure!( + matches!(args[1], DType::Primitive(PType::I64, _)), + "bloom needle must be i64" + ); + Ok(DType::Bool(args[0].nullability() | args[1].nullability())) + } + + fn execute( + &self, + options: &Self::Options, + args: &dyn ExecutionArgs, + ctx: &mut ExecutionCtx, + ) -> VortexResult { + let filters = args.get(0)?.execute::(ctx)?; + let needle_array = args.get(1)?; + let needle = needle_array + .as_constant() + .ok_or_else(|| vortex_err!("bloom needle must be constant"))?; + let Some(needle) = i64_value(&needle)? else { + return Ok(ConstantArray::new( + Scalar::null(DType::Bool(Nullability::Nullable)), + args.row_count(), + ) + .into_array()); + }; + + let validity = filters.varbinview_validity(); + let valid = validity.execute_mask(filters.len(), ctx)?; + let mut possible = Vec::with_capacity(filters.len()); + let mut set_bits = 0u64; + let mut valid_zones = 0usize; + for (idx, is_valid) in valid.iter().enumerate() { + if is_valid { + let filter = filters.bytes_at(idx); + vortex_ensure!( + filter.len() == options.bytes().get(), + "stored bloom byte length does not match options" + ); + set_bits += filter + .as_slice() + .iter() + .map(|byte| u64::from(byte.count_ones())) + .sum::(); + valid_zones += 1; + possible.push(bloom_contains( + filter.as_slice(), + needle, + options.hashes().get(), + )); + } else { + possible.push(false); + } + } + if diagnostics_enabled() && valid_zones > 0 { + let total_bits = valid_zones as f64 * options.bytes().get() as f64 * 8.0; + let possible_zones = possible.iter().filter(|value| **value).count(); + tracing::info!( + target: "vortex_layout::skip_index", + index = "bloom", + needle, + zones = valid_zones, + definitely_absent_zones = valid_zones - possible_zones, + average_fill_ratio = set_bits as f64 / total_bits, + "skip-index probe" + ); + } + Ok(BoolArray::new(BitBuffer::from_iter(possible), validity).into_array()) + } + + fn is_null_sensitive(&self, _options: &Self::Options) -> bool { + false + } + + fn is_fallible(&self, _options: &Self::Options) -> bool { + false + } +} + +/// Equality rewrite that turns a Bloom miss into a zone falsifier. +#[derive(Clone, Debug)] +struct BloomEqRewrite { + options: BloomOptions, +} + +impl StatsRewriteRule for BloomEqRewrite { + fn scalar_fn_id(&self) -> ScalarFnId { + Binary.id() + } + + fn falsify( + &self, + expr: &Expression, + ctx: &StatsRewriteCtx<'_>, + ) -> VortexResult> { + if *expr.as_::() != Operator::Eq { + return Ok(None); + } + + let (column, literal) = if is_root(expr.child(0)) && expr.child(1).is::() { + (expr.child(0), expr.child(1)) + } else if is_root(expr.child(1)) && expr.child(0).is::() { + (expr.child(1), expr.child(0)) + } else { + return Ok(None); + }; + if !matches!(ctx.return_dtype(column)?, DType::Primitive(PType::I64, _)) + || literal.as_::().is_null() + { + return Ok(None); + } + + let filter = stat(column.clone(), BloomFilter.bind(self.options.clone())); + let contains = BloomContains.new_expr(self.options.clone(), [filter, literal.clone()]); + Ok(Some(not(contains))) + } +} + +fn diagnostics_enabled() -> bool { + std::env::var("VORTEX_SKIP_INDEX_DIAGNOSTICS").is_ok_and(|value| value == "1") +} + +#[cfg(test)] +mod tests { + use std::num::NonZeroU8; + use std::num::NonZeroUsize; + use std::sync::Arc; + + use vortex_array::arrays::StructArray; + use vortex_array::dtype::DType; + use vortex_array::dtype::Nullability; + use vortex_array::dtype::PType; + use vortex_array::expr::eq; + use vortex_array::expr::lit; + use vortex_array::expr::root; + use vortex_array::validity::Validity; + use vortex_error::VortexResult; + + use super::BloomOptions; + use super::BloomSkipIndex; + use super::SkipIndex; + use crate::layouts::zoned::zone_map::ZoneMap; + + fn small_options() -> BloomOptions { + BloomOptions::new( + NonZeroUsize::new(64).expect("64 is non-zero"), + NonZeroU8::new(3).expect("3 is non-zero"), + ) + } + + #[test] + fn missing_stat_stays_inconclusive() -> VortexResult<()> { + let session = vortex_array::array_session(); + let index = BloomSkipIndex::new(small_options()); + index.register(&session); + let predicate = eq(root(), lit(42i64)); + let proof = predicate + .falsify( + &DType::Primitive(PType::I64, Nullability::NonNullable), + &session, + )? + .expect("equality has a bloom proof"); + + let zone_map = ZoneMap::try_new( + DType::Primitive(PType::I64, Nullability::NonNullable), + StructArray::try_new(Vec::<&str>::new().into(), vec![], 2, Validity::NonNullable)?, + Arc::new([]), + 8, + 16, + )?; + assert!(zone_map.prune(&proof, &session)?.all_false()); + Ok(()) + } +} diff --git a/vortex-layout/src/layouts/zoned/skip_index/mod.rs b/vortex-layout/src/layouts/zoned/skip_index/mod.rs new file mode 100644 index 00000000000..2def62acab0 --- /dev/null +++ b/vortex-layout/src/layouts/zoned/skip_index/mod.rs @@ -0,0 +1,59 @@ +//! Skipping-index interface and implementations. + +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright the Vortex contributors + +use std::fmt::Debug; +use std::sync::Arc; + +use vortex_array::aggregate_fn::AggregateFnRef; +use vortex_array::dtype::DType; +use vortex_error::VortexResult; +use vortex_error::vortex_err; +use vortex_session::VortexSession; + +use super::aggregates::default_zoned_aggregate_fns; +use super::writer::ZonedLayoutOptions; + +pub mod bloom; + +/// One definition that supplies a persisted aggregate and registers every read-side component +/// needed to consult it. +/// +/// The writer helper [`ZonedLayoutOptions::with_skip_index`] is the explicit per-column declaration +/// seam. Readers call [`SkipIndex::register`] on their session before opening the file. +pub trait SkipIndex: Debug + Send + Sync + 'static { + /// The aggregate state to persist for `input_dtype`, or `None` when unsupported. + fn aggregate_fn(&self, input_dtype: &DType) -> Option; + + /// Register the aggregate, optional probe function, and predicate rewrite as one operation. + fn register(&self, session: &VortexSession); +} + +impl ZonedLayoutOptions { + /// Add `index` to this zoned writer while retaining the default min/max-style aggregates. + /// + /// `WriteStrategyBuilder::with_field_zoned_options` can install the configured options for one + /// field while retaining the default data layout pipeline. + pub fn with_skip_index( + mut self, + index: &I, + input_dtype: &DType, + session: &VortexSession, + ) -> VortexResult { + let aggregate_fn = index + .aggregate_fn(input_dtype) + .ok_or_else(|| vortex_err!("skip index does not support input dtype {input_dtype}"))?; + + let mut aggregate_fns = self + .aggregate_fns + .take() + .unwrap_or_else(|| default_zoned_aggregate_fns(input_dtype, session)) + .to_vec(); + if !aggregate_fns.iter().any(|stored| stored == &aggregate_fn) { + aggregate_fns.push(aggregate_fn); + } + self.aggregate_fns = Some(Arc::from(aggregate_fns)); + Ok(self) + } +} diff --git a/vortex-layout/src/layouts/zoned/writer.rs b/vortex-layout/src/layouts/zoned/writer.rs index 42909179bf2..4ace2a57eeb 100644 --- a/vortex-layout/src/layouts/zoned/writer.rs +++ b/vortex-layout/src/layouts/zoned/writer.rs @@ -13,21 +13,6 @@ use vortex_array::ArrayContext; use vortex_array::IntoArray; use vortex_array::VortexSessionExecute; use vortex_array::aggregate_fn::AggregateFnRef; -use vortex_array::aggregate_fn::AggregateFnVTable; -use vortex_array::aggregate_fn::AggregateFnVTableExt; -use vortex_array::aggregate_fn::EmptyOptions; -use vortex_array::aggregate_fn::NumericalAggregateOpts; -use vortex_array::aggregate_fn::fns::bounded_max::BoundedMax; -use vortex_array::aggregate_fn::fns::bounded_max::BoundedMaxOptions; -use vortex_array::aggregate_fn::fns::bounded_min::BoundedMin; -use vortex_array::aggregate_fn::fns::bounded_min::BoundedMinOptions; -use vortex_array::aggregate_fn::fns::max::Max; -use vortex_array::aggregate_fn::fns::min::Min; -use vortex_array::aggregate_fn::fns::nan_count::NanCount; -use vortex_array::aggregate_fn::fns::null_count::NullCount; -use vortex_array::aggregate_fn::fns::sum::Sum; -use vortex_array::aggregate_fn::session::AggregateFnSessionExt; -use vortex_array::dtype::DType; use vortex_error::VortexError; use vortex_error::VortexResult; use vortex_io::session::RuntimeSessionExt; @@ -40,7 +25,7 @@ use crate::LayoutStrategy; use crate::layouts::zoned::AggregateStatsAccumulator; use crate::layouts::zoned::ZonedLayout; use crate::layouts::zoned::aggregate_partials; -use crate::layouts::zoned::schema::default_bounded_stat_max_bytes; +use crate::layouts::zoned::aggregates::default_zoned_aggregate_fns; use crate::segments::SegmentSinkRef; use crate::sequence::SendableSequentialStream; use crate::sequence::SequencePointer; @@ -196,94 +181,3 @@ impl LayoutStrategy for ZonedStrategy { self.child.buffered_bytes() + self.stats.buffered_bytes() } } - -pub(super) fn default_zoned_aggregate_fns( - dtype: &DType, - session: &VortexSession, -) -> Arc<[AggregateFnRef]> { - let (max, min) = match dtype { - DType::Utf8(_) | DType::Binary(_) => ( - BoundedMax.bind(BoundedMaxOptions { - max_bytes: default_bounded_stat_max_bytes(), - }), - BoundedMin.bind(BoundedMinOptions { - max_bytes: default_bounded_stat_max_bytes(), - }), - ), - _ => ( - Max.bind(NumericalAggregateOpts::skip_nans()), - Min.bind(NumericalAggregateOpts::skip_nans()), - ), - }; - - let mut aggregate_fns = vec![max, min]; - if Sum - .return_dtype(&NumericalAggregateOpts::skip_nans(), dtype) - .is_some() - { - aggregate_fns.push(Sum.bind(NumericalAggregateOpts::skip_nans())); - } - aggregate_fns.push(NanCount.bind(EmptyOptions)); - aggregate_fns.push(NullCount.bind(EmptyOptions)); - - // Stats from geo extension types are discovered from the registry at runtime instead. - aggregate_fns.extend(session.aggregate_fns().zone_stat_defaults(dtype)); - - aggregate_fns.into() -} - -#[cfg(test)] -mod tests { - use vortex_array::aggregate_fn::fns::bounded_max::BoundedMax; - use vortex_array::aggregate_fn::fns::bounded_min::BoundedMin; - use vortex_array::aggregate_fn::fns::max::Max; - use vortex_array::aggregate_fn::fns::min::Min; - use vortex_array::aggregate_fn::fns::sum::Sum; - use vortex_array::dtype::Nullability; - use vortex_array::dtype::PType; - use vortex_array::extension::datetime::TimeUnit; - use vortex_array::extension::datetime::Timestamp; - - use super::*; - - #[test] - fn default_aggregates_bound_variable_length_min_max() { - let aggregate_fns = default_zoned_aggregate_fns( - &DType::Utf8(Nullability::NonNullable), - &vortex_array::array_session(), - ); - - assert_eq!( - aggregate_fns[0].as_::().max_bytes, - default_bounded_stat_max_bytes() - ); - assert_eq!( - aggregate_fns[1].as_::().max_bytes, - default_bounded_stat_max_bytes() - ); - } - - #[test] - fn default_aggregates_keep_fixed_width_min_max_exact() { - let aggregate_fns = - default_zoned_aggregate_fns(&PType::I32.into(), &vortex_array::array_session()); - - assert!(aggregate_fns[0].is::()); - assert!(aggregate_fns[1].is::()); - assert!(aggregate_fns[2].is::()); - } - - #[test] - fn default_aggregates_skip_sum_for_non_summable_dtype() { - let dtype = DType::Extension( - Timestamp::new(TimeUnit::Microseconds, Nullability::Nullable).erased(), - ); - let aggregate_fns = default_zoned_aggregate_fns(&dtype, &vortex_array::array_session()); - - assert!( - aggregate_fns - .iter() - .all(|aggregate_fn| !aggregate_fn.is::()) - ); - } -} From 0fea7d2ff4014b1226d1e599651347aee6b0e52d Mon Sep 17 00:00:00 2001 From: Connor Tsui Date: Thu, 23 Jul 2026 14:23:12 -0400 Subject: [PATCH 13/13] Remove bloom skip-index diagnostics Signed-off-by: Connor Tsui --- .github/workflows/sql-benchmarks.yml | 1 - .../src/layouts/zoned/skip_index/bloom.rs | 25 ------------------- 2 files changed, 26 deletions(-) diff --git a/.github/workflows/sql-benchmarks.yml b/.github/workflows/sql-benchmarks.yml index 9bdca960aa7..2ed63a14e73 100644 --- a/.github/workflows/sql-benchmarks.yml +++ b/.github/workflows/sql-benchmarks.yml @@ -540,7 +540,6 @@ jobs: env: VORTEX_EXPERIMENTAL_PATCHED_ARRAY: "1" VORTEX_BLOOM_SKIP_INDEX: ${{ inputs.bloom_skip_index && '1' || '' }} - VORTEX_SKIP_INDEX_DIAGNOSTICS: ${{ inputs.bloom_skip_index && '1' || '' }} VORTEX_BLOOM_SKIP_INDEX_DIR: ${{ inputs.bloom_skip_index && 'vortex-file-bloom' || '' }} FLAT_LAYOUT_INLINE_ARRAY_NODE: "1" # Makes python output nicer diff --git a/vortex-layout/src/layouts/zoned/skip_index/bloom.rs b/vortex-layout/src/layouts/zoned/skip_index/bloom.rs index 74906c2b6ca..7c6985126f3 100644 --- a/vortex-layout/src/layouts/zoned/skip_index/bloom.rs +++ b/vortex-layout/src/layouts/zoned/skip_index/bloom.rs @@ -148,8 +148,6 @@ impl ScalarFnVTable for BloomContains { let validity = filters.varbinview_validity(); let valid = validity.execute_mask(filters.len(), ctx)?; let mut possible = Vec::with_capacity(filters.len()); - let mut set_bits = 0u64; - let mut valid_zones = 0usize; for (idx, is_valid) in valid.iter().enumerate() { if is_valid { let filter = filters.bytes_at(idx); @@ -157,12 +155,6 @@ impl ScalarFnVTable for BloomContains { filter.len() == options.bytes().get(), "stored bloom byte length does not match options" ); - set_bits += filter - .as_slice() - .iter() - .map(|byte| u64::from(byte.count_ones())) - .sum::(); - valid_zones += 1; possible.push(bloom_contains( filter.as_slice(), needle, @@ -172,19 +164,6 @@ impl ScalarFnVTable for BloomContains { possible.push(false); } } - if diagnostics_enabled() && valid_zones > 0 { - let total_bits = valid_zones as f64 * options.bytes().get() as f64 * 8.0; - let possible_zones = possible.iter().filter(|value| **value).count(); - tracing::info!( - target: "vortex_layout::skip_index", - index = "bloom", - needle, - zones = valid_zones, - definitely_absent_zones = valid_zones - possible_zones, - average_fill_ratio = set_bits as f64 / total_bits, - "skip-index probe" - ); - } Ok(BoolArray::new(BitBuffer::from_iter(possible), validity).into_array()) } @@ -236,10 +215,6 @@ impl StatsRewriteRule for BloomEqRewrite { } } -fn diagnostics_enabled() -> bool { - std::env::var("VORTEX_SKIP_INDEX_DIAGNOSTICS").is_ok_and(|value| value == "1") -} - #[cfg(test)] mod tests { use std::num::NonZeroU8;