Poc index layout - #9706
Conversation
Adds an indexed layout to the Vortex layout crate based on vortex-data#9024
Intended as an example implementation of a indexed-layout index.
Merging this PR will degrade performance by 16.97%
|
| Mode | Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|---|
| ❌ | WallTime | multiply_shapes_neon[(128, PerRowPerRow)] |
2 µs | 3.8 µs | -48.18% |
| ❌ | WallTime | add_shapes_neon[(128, PerRowPerRow)] |
1.8 µs | 3.1 µs | -41.31% |
| ❌ | WallTime | arrow_checked_add_u32_neon[16384] |
12.4 µs | 20.3 µs | -38.91% |
| ❌ | WallTime | subtract_shapes_neon[(128, PerRowPerRow)] |
1.9 µs | 2.9 µs | -34.22% |
| ❌ | Simulation | cached_indices_i128[0.01] |
48 µs | 61.6 µs | -22.01% |
| ❌ | WallTime | add_u32_nonnull_neon |
6.6 µs | 7.6 µs | -12.99% |
| ❌ | WallTime | add_i64_nonnull_neon |
9.8 µs | 11.2 µs | -12.95% |
| ❌ | Simulation | compress_fsst[(500, 64, 8)] |
482.1 µs | 536.2 µs | -10.09% |
| ⚡ | Simulation | cached_indices_i32[0.01] |
51.6 µs | 37.5 µs | +37.36% |
| ⚡ | WallTime | arrow_checked_add_u32_avx512[16384] |
21.3 µs | 17.6 µs | +20.77% |
| ⚡ | WallTime | arrow_checked_add_u32_avx2[16384] |
21.3 µs | 17.7 µs | +20.16% |
Tip
Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.
Comparing polarsignals:poc-index-layout (f81f922) with develop (97953be)
Footnotes
-
206 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩
-
4 benchmarks were run, but are now archived. If they were deleted in another branch, consider rebasing to remove them from the report. Instead if they were added back, click here to restore them. ↩
| let data_eval = self | ||
| .data_child()? | ||
| .pruning_evaluation(row_range, expr, mask.clone())?; | ||
|
|
||
| let Some(probe) = self.probe(expr)? else { | ||
| return Ok(data_eval); | ||
| }; |
There was a problem hiding this comment.
is it worth checking if the probe is cached and not checking data child pruning eval if it prunes the whole range?
| /// Decide whether this index can serve `expr`, a single conjunct scoped to the data child's | ||
| /// dtype. | ||
| /// | ||
| /// `None` means "no claim" and is always safe: the scan falls back to the data child. | ||
| fn plan( | ||
| &self, | ||
| expr: &BoundExpression, | ||
| dtype: &DType, | ||
| options: &[u8], | ||
| ) -> VortexResult<Option<IndexQueryPlan>>; |
There was a problem hiding this comment.
do you expect the plan to be a pruning expr or a regular expression?
There was a problem hiding this comment.
See
We currently convert expressions into pruning expression before calling prune_expression.I am not sure on this design but this is what happens now.
| fn builder( | ||
| &self, | ||
| dtype: &DType, | ||
| options: &[u8], |
There was a problem hiding this comment.
It lets the writer configure a specific attachment of that index kind — e.g. a bloom filter's bit width or hash count, a trigram index's n-gram size.
| //! A prototype of the `vortex.indexed` layout proposed in | ||
| //! [vortex-data/vortex#9024](https://github.com/vortex-data/vortex/issues/9024). | ||
| //! | ||
| //! A `vortex.indexed` layout wraps a data layout with zero or more *locating indexes* held as | ||
| //! auxiliary children. Writers build indexes through the pluggable [`IndexVTable`] registry while | ||
| //! streaming; readers probe those indexes to prune (or outright answer) filter predicates, and | ||
| //! fall back to a plain scan of the data child whenever an index is missing, unknown, or has no | ||
| //! claim on the expression. | ||
| //! | ||
| //! Indexes are optional at every stage. A builder that finds nothing worth keeping declines once |
There was a problem hiding this comment.
do you expect nested indexes, or a single index from the root for each leaf?
We would for example have struct({a: Indexed(struct{b: Indexed(Flat)})}). This is important since it informs how we probe children and merge indexes.
It would be that Index outer or inner is more precise.
Worth a comment on how they compose
| RowLocator::Rows(rows) => { | ||
| let start = u32::try_from(row_range.start)?; | ||
| let end = u32::try_from(row_range.end)?; | ||
| let mut pos = row_range.start; | ||
| for row in rows.range(start..end) { | ||
| let row = u64::from(row); | ||
| bits.append_n(false, usize::try_from(row - pos)?); | ||
| bits.append_n(true, 1); | ||
| pos = row + 1; | ||
| } | ||
| bits.append_n(false, usize::try_from(row_range.end - pos)?); | ||
| } | ||
| // Broadcast each block's bit across the rows it covers, clipped to `row_range`. | ||
| RowLocator::Blocks { block_len, ids } => { | ||
| let mut row = row_range.start; | ||
| while row < row_range.end { | ||
| let block = row / block_len; | ||
| let block_end = ((block + 1) * block_len).min(row_range.end); | ||
| let hit = ids.contains(u32::try_from(block)?); | ||
| bits.append_n(hit, usize::try_from(block_end - row)?); | ||
| row = block_end; | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
This looks quite low. append_n is pretty expensive. Can be optimised later
| /// [`IndexSession`](crate::layouts::indexed::session::IndexSession) under a stable string id, | ||
| /// which is what gets written into the layout metadata. A reader that does not have the kind | ||
| /// registered drops the index child and reads the data child directly. | ||
| pub trait IndexVTable: 'static + Send + Sync + Debug { |
There was a problem hiding this comment.
Where do we define what layout children are allowed for each index?
At some point we convert a SendableArrayStream into a sterilised layout and later back..
What is the code that validate serde for this
|
Looking promising |
| pub trait IndexResolve: 'static + Send + Sync { | ||
| /// `postings` are the index-child rows that survived [`IndexQueryPlan::filter`], projected in | ||
| /// the index child's own schema. | ||
| fn resolve( | ||
| &self, | ||
| postings: &ArrayRef, | ||
| data_row_count: u64, | ||
| ctx: &mut ExecutionCtx, | ||
| ) -> VortexResult<RowLocator>; |
There was a problem hiding this comment.
should we allow this to specify a row range and avoid loading in data for values outside that row_range?
There was a problem hiding this comment.
Then we could use this index with conjunction with a selection to only load some of the index
There was a problem hiding this comment.
I wonder if should have the following:
- a way to specify if the index data is contiguous or spread out
- a way to chunk index are different size (a bloom filter over the whole file, tri-gram index of every 250k row)
There was a problem hiding this comment.
Two support the second we would need to change the layout strategy to support chunking of some of the children layouts (likely have a strategy that chunks that layout child at the boundary specified so reading back that row range actually reads only that chunk).
There was a problem hiding this comment.
I believe we can already perform number 2 if we simply perform nesting of the layouts:
e.g: Indexed(Indexed(data, ChunkedLayout), FlatLayout)
There was a problem hiding this comment.
I believe the first bullet can be implemented with composition as well right? Sorry if these are stupid questions.
Chunked(ChunkBy::RowCount(100_000), Indexed(...))
Or is this not quite what you meant?
vortex-reverse-index was a standalone worked example of the vortex.indexed IndexVTable contract; move it to vortex-layout/examples/reverse_index, following the plain cargo-example convention used by vortex-ffi/examples instead of shipping it as its own workspace crate. vortex-datafusion could no longer dev-depend on it as a library once it became a cargo example, so its DataFusion integration test moves along with it (examples/reverse_index/datafusion_tests.rs), rebuilt against vortex-datafusion's public API and a manually assembled session instead of the vortex facade crate, which vortex-layout cannot depend on. Signed-off-by: "Thor" <thor.hansen@dash0.com>
IndexedReader::plan_probe returned on the first spec that claimed an expression, regardless of exactness. A Superset claim from an earlier spec could shadow a later, more precise Exact claim, and multiple Superset claims on the same expression never got combined. CachedProbe is now Exact(locator) or Superset(Vec<locator>): plan_probe scans every spec, preferring any Exact claim outright and otherwise collecting all Superset claims. pruning_evaluation intersects every Superset locator (short-circuiting once nothing survives), and filter_evaluation only fast-paths on an Exact claim. Signed-off-by: "Thor" <thor.hansen@dash0.com>
Summary
This is a PoC of the Indexed Layout described in the above issue.
Changes
This implements the
IndexVTabletrait that Index implementations can implement to be utilized in an IndexLayout.Adds an example
ReverseIndexthat implements the trait and it's use is demonstrated in a couple unit tests. Additionally adds a datafusion test that validates that fewer bytes are read from a Vortex file when an indexed layout is successfully utilized.