perf: plan take operations through FilteredReadExec's range-read path#7672
perf: plan take operations through FilteredReadExec's range-read path#7672LuQQiu wants to merge 10 commits into
Conversation
TakeExec reads rows through the v2 readers' point-lookup path with serial
per-fragment opens; reading the same rows through FilteredReadExec's planned
range reads is 2.3-2.7x faster (take-100 benchmark, warm NVMe).
Generalize FilteredReadExec's input to "a plan that tells the node which rows
to read", accepting two encodings detected from the input plan's schema:
- the serialized IndexExprResult mask batch (existing behavior, unchanged)
- any plan carrying a _rowid/_rowaddr column ("take mode", new): each batch
is converted to an in-memory IndexExprResult and read through the same
plan_scan -> read_fragment pipeline, then the columns are merged back into
the input batch preserving row order, duplicates, and payload columns
(e.g. _distance) - the same contract as TakeExec
Scanner::take and merge_insert now plan takes as FilteredReadExec on the v2
storage format. TakeExec is unchanged and still used for legacy (v1) storage.
The CoalesceTake optimizer rule also collapses stacked take-mode reads, and
count-pushdown / distributed proto serialization explicitly skip take-mode
nodes (distributed support is a follow-up).
Adds a three-arm benchmark (TakeExec vs mask mode vs take mode) at
rust/lance/benches/take_exec_compare.rs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
gen_range is deprecated and fails clippy -D warnings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Box the TakeRows variant (large size difference between variants) and allow print_stdout in the benchmark like the other benches. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A random take touches many fragments (often one row per fragment) and try_join_all drives the opens concurrently but on a single task, so the CPU-bound part of ~100 fragment opens ran back to back (~4ms/query on a many-fragment dataset). Spawn one task per fragment open and per decode, matching the scan path (FilteredReadStream::try_new). Local take-100 (2M rows, 20 fragments): take mode 671 -> 858 QPS, now within 7% of mask mode (920 QPS) vs TakeExec at 445 QPS. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
Review feedback: the node read as if it had "modes" (mode=take display, is_take() checks). Reframe it as one general I/O node, output = read(row_source, fields_to_read) + carry_columns, where only the row source varies: AllRows (no input), RowSet (one serialized IndexExprResult batch; sets are unordered and unique by nature), or RowStream (record batches whose order, duplicates, and columns are preserved). The source kind is derived from the input plan's schema, never configured. Renames only, no behavior change: FilteredReadInput -> RowSource, TakeRowsInput -> RowStreamSource, is_take() -> row_stream_input(), mode=take(_rowid) -> source=stream(_rowid) in plan text, and consumer checks (count pushdown, proto, CoalesceTake) now ask attributes instead of node identity. Also documents the legacy-storage invariant at both TakeExec swap sites (the v1 reader cannot serve FilteredRead) and the deferred count-through-streams rewrite in count_pushdown.rs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review feedback: the three-arm comparison harness is a local tool, not a maintained benchmark. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
westonpace
left a comment
There was a problem hiding this comment.
Initial feedback. Some of these comments are pretty verbose and I'd prefer to trim them down if possible. Overall I think the strategy is fine.
Also, why can't we use row addresses as input unless we have stable row id? I would think we could use addresses or ids as input with or without stable row id.
| /// Who names the rows that a [`FilteredReadExec`] reads. | ||
| /// | ||
| /// The node itself is a single, general I/O operator: | ||
| /// | ||
| /// ```text | ||
| /// output = read(row_source, fields_to_read) ⊕ carry_columns | ||
| /// | ||
| /// schema() = carry_columns + fields_to_read (uniform for every source) | ||
| /// ``` | ||
| /// | ||
| /// Only the row source varies, and the differences between the variants are | ||
| /// inherent to what the sources *are* — not modes of the node: | ||
| /// | ||
| /// - [`AllRows`](Self::AllRows): no input plan; every live row. No carry | ||
| /// columns. | ||
| /// - [`RowSet`](Self::RowSet): a *set* of rows, delivered as exactly one | ||
| /// batch in the serialized [`IndexExprResult`] wire layout (produced by | ||
| /// e.g. [`ScalarIndexExec`](super::scalar_index::ScalarIndexExec) or a | ||
| /// `_rowid IN (...)` filter). Sets are unordered and unique by nature, so | ||
| /// the output is in storage order and deduplicated. No carry columns. | ||
| /// - [`RowStream`](Self::RowStream): a *stream* of rows — ordinary record | ||
| /// batches with a `_rowid`/`_rowaddr` column. Streams have row order, | ||
| /// duplicates, and their own columns, and all three are preserved: the | ||
| /// stream's columns are carried through to the output next to the newly | ||
| /// read fields (the contract [`TakeExec`](super::TakeExec) provides). | ||
| /// Internally each batch is converted to an in-memory [`IndexExprResult`], | ||
| /// so a stream is the streaming generalization of a set. | ||
| /// | ||
| /// The source kind is *derived*, never configured: [`FilteredReadExec::try_new`] | ||
| /// inspects the input plan's schema (the wire layouts are unmistakable — | ||
| /// binary mask columns; anything else must carry a row id or row address | ||
| /// column). |
There was a problem hiding this comment.
This comment seems excessively verbose and is hard to parse. I like the description of each of the options, but those descriptions should be on the enum choices themselves, and not in this header. Maybe just something like:
/// Describes which rows should be read
///
/// This can be all rows, a specific set of rows, or the read can
/// be a "take" which reads new columns into an existing set of rows
/// using the `_rowid` or `_rowaddr`.
| /// | ||
| /// `index_input` generalizes to "a plan that tells this node which rows to | ||
| /// read" and accepts two encodings, detected from the plan's schema: | ||
| /// | ||
| /// - A serialized [`IndexExprResult`] batch (the wire layout emitted by | ||
| /// [`ScalarIndexExec`](super::scalar_index::ScalarIndexExec)): a row | ||
| /// *set* scoping a scan. This is the classic behavior. | ||
| /// - Any other plan carrying a `_rowid` or `_rowaddr` column: a row | ||
| /// *stream* of rows. The node reads `options.projection` for exactly | ||
| /// those rows and merges the columns into the stream's batches, | ||
| /// preserving row order, duplicates, and the stream's own columns — | ||
| /// the same contract as [`TakeExec`](super::TakeExec). Fields already | ||
| /// present in the stream's schema are not re-read. |
There was a problem hiding this comment.
This comment feels pretty repetitive. Also, we should rename index_input since it isn't necessarily index-specific anymore. Perhaps just:
/// Create a new filtered read
///
/// `input` identifies which rows to read. This is parsed into
/// a [`RowStreamSource`]
| if dataset.manifest.uses_stable_row_ids() { | ||
| return Err(Error::invalid_input_source( | ||
| format!( | ||
| "cannot read rows by '{}' on a dataset with stable row ids; the input plan must provide '{}'", | ||
| ROW_ADDR, ROW_ID | ||
| ) | ||
| .into(), | ||
| )); | ||
| } | ||
| ROW_ADDR |
There was a problem hiding this comment.
Why not? It almost seems like this would be the easier case since row addresses are what we ultimately need to locate the rows.
| // 4 - Take the mapped row ids. On the v2 storage format the take is | ||
| // planned as a FilteredReadExec fed by the index-mapper stream: | ||
| // the row ids become a row-id mask read through the planned | ||
| // range-read path, which is considerably faster than TakeExec's | ||
| // point-lookup path. Both nodes have the same output contract | ||
| // (input columns first, then the fetched columns). | ||
| // | ||
| // INVARIANT: every site that replaces TakeExec with | ||
| // FilteredReadExec must check is_legacy_storage() first. The v1 | ||
| // reader's read_ranges_tasks is a stub that errors ("Attempt to | ||
| // perform FilteredRead on v1 files"), so TakeExec remains the | ||
| // only take that can read v1 files. |
There was a problem hiding this comment.
Do we really need this verbose comment?
There was a problem hiding this comment.
I will clean up the verbose comments after all the code changes looks good (otherwise agent tend to add back a lot of comments when i delete them up front lollll)
| // A read with a row-stream source emits one row per input row; its count | ||
| // is driven by the input plan, not by fragment metadata. | ||
| // | ||
| // COUNT over such a read == the count of input rows whose id still | ||
| // exists. A future CountLiveRowsExec could answer that without any | ||
| // column I/O: stream the input and, per batch, build the live-row set | ||
| // from fragment metadata + deletion vectors and count member rows | ||
| // (per row, so duplicates count). Deferred because no plan shape today | ||
| // places a row-stream read under a COUNT — count plans request no | ||
| // columns, so the scanner never builds one. |
There was a problem hiding this comment.
| // A read with a row-stream source emits one row per input row; its count | |
| // is driven by the input plan, not by fragment metadata. | |
| // | |
| // COUNT over such a read == the count of input rows whose id still | |
| // exists. A future CountLiveRowsExec could answer that without any | |
| // column I/O: stream the input and, per batch, build the live-row set | |
| // from fragment metadata + deletion vectors and count member rows | |
| // (per row, so duplicates count). Deferred because no plan shape today | |
| // places a row-stream read under a COUNT — count plans request no | |
| // columns, so the scanner never builds one. | |
| // We don't currently support count pushdown when the row selector | |
| // is a row stream. |
| // One read round per input batch: read each fragment's rows as a | ||
| // single batch, and prioritize earlier input batches so consuming | ||
| // the (ordered) output is not starved by later batches | ||
| scoped.priority = batch_index; |
There was a problem hiding this comment.
Hmm, plan_to_scoped_fragments is already going to assign a priority (based on fragment order). I'd rather not lose this. In other words, if we have 4 input batches and 2 fragments then instead of using priorities 0, 0, 1, 1, 2, 2, 3, 3 I'd rather see 0, 1, 2, 3, 4, 5, 6, 7.
Can batch_index be a counter instead so we can do something like...
for scoped in &mut scoped_fragments {
scoped.priority += batch_index;
}
batch_index += scoped_fragments.len();
| ) | ||
| }) | ||
| .boxed() | ||
| .try_buffered(get_num_compute_intensive_cpus()) |
There was a problem hiding this comment.
This probably should not be get_num_compute_intensive_cpus as we are not really trying to split up CPU work. It's not really I/O work either. It's more of a "prefetch" factor.
The case for a large value, is when we have lots of input rows from a very fast source and a very small number of fragments. For example, maybe we have 1M input rows, available almost immediately, in batches of 8Ki, and each batch hits one fragment. Without enough buffering here we might not get enough I/O parallelism.
The only case I see for a small value, is when we have carry-through columns that are large. In that case if we buffer too much, we will accumulate a lot of carry-through data, which creates a lot of RAM pressure.
Maybe for now we hard-code it to something like 64. I think, in all current cases, we are unlikely to get more than 1 or 2 input batches anyways, so it is a moot point.
If we do start to have large input streams in the future then we should tie it into the memory pool reservation system. We should try and grab a reservation for the carry-through data as each batch of input arrives. If we succeed, we spawn a map_batch call on it. If we fail, then we pause reading the input until we can get the reservation.
| // target stays at the scanner batch size because take output | ||
| // batches mirror the input batches, and `batch_size` is a | ||
| // documented maximum for output batch sizes. | ||
| let coalesced = Arc::new(CoalesceBatchesExec::new(input, self.get_batch_size())); |
There was a problem hiding this comment.
Should the coalescing be an implicit part of the filtered read? I guess it could work either way but I feel like coalescing is very important here.
| if let Some(fragments) = &self.fragments { | ||
| read_options = read_options.with_fragments(Arc::new(fragments.clone())); | ||
| } |
There was a problem hiding this comment.
What does a "take with fragments" mean? Do you have any tests that cover this case?
|
|
||
| // Align the read rows (dataset order, deduplicated) back to the | ||
| // input's row order via the key column | ||
| let read_keys = read_data |
There was a problem hiding this comment.
I believe the code in take had a fast path to skip alignment if it wasn't needed (the order was already correct). Is that happening here?
A row-stream read now gathers its input into rounds of batch_size rows (explicit option, then LANCE_DEFAULT_BATCH_SIZE, then a flat 8192) before planning each read: tiny batches merge so planning overhead amortizes, and oversized batches split so a round's decoded output stays bounded. The external CoalesceBatchesExec in Scanner::take is gone - the node sizes its own rounds, and every row-stream consumer (e.g. merge_insert) now gets coalescing for free. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A physical address already encodes (fragment, offset), so an address-keyed round now builds its read ranges directly instead of translating through the row-id sequence (which would misread addresses as stable row ids - the reason this case was previously rejected). Row ids keep the mask path; addresses at deleted rows drop like stale keys. Scanner::take can now hand any keyed input to FilteredReadExec, shrinking the TakeExec fallback to legacy storage only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Round reads now keep both levels of I/O-scheduler ordering (rounds strictly ordered, fragments in dataset order within a round) via a stride instead of flattening each round to one priority. The round pipeline depth is a named prefetch constant (64) rather than the CPU count - the work is I/O bound and the window is what keeps the scheduler saturated on long input streams. When a round's read returns exactly one row per input row with an identical key sequence (storage-ordered input), the hash-map alignment and permutation are skipped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Renames per review: the enum is RowSelector, try_new's parameter is simply `input`, and the enum/constructor docs shrink to short forms with per-variant descriptions on the variants. The legacy-storage and count-pushdown comments trim to one-liners. Also adds a unit test for a fragment-scoped take (in-scope key read, out-of-scope key dropped). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Motivation
TakeExecmaterializes rows through the v2 readers' point-lookup path (ReadBatchParams::Indices)with per-fragment reader opens driven on a single task. Reading the same rows through
FilteredReadExec's planned range reads is much faster. Take-100 benchmark (100 random rowaddresses per query, warm NVMe, heavy ~1KiB string column; harness kept out of the tree):
_rowid IN (...))Carrying a payload column (e.g.
_distance/_score) through the row-stream read costs ~2% —the capability a row-set input cannot express.
Design: one I/O node over a row source
FilteredReadExecstays a single, general I/O node:Only the row source — who names the rows — varies, and the differences between sources are
inherent to what they are, not modes of the node:
IndexExprResultbatch (scalar index result,_rowid IN) — existing behavior, unchanged_rowid/_rowaddrcolumn_distance), preservedThe source kind is derived from the input plan's schema at construction — never configured
(the wire layouts are unmistakable; anything else must carry a row id/address column). Each
stream batch is converted to an in-memory
IndexExprResultand read through the sameplan_scan->plan_to_scoped_fragments->read_fragmentpipeline as a set; the readcolumns are then aligned (hash on the key column, O(N)) and merged back into the batch —
including nested-struct merges, via the same
calculate_output_schema+merge_with_schemacontract as
TakeExec. Streaming batch-by-batch: no input draining, same memory profile asTakeExec.Call sites:
Scanner::take()and merge_insert's indexed take now plan takes asFilteredReadExecon the v2 storage format. Plan text showsLanceRead: ..., projection=[...], source=stream(_rowid)whereTake: columns=...appearedbefore.
Design notes (from review)
read_ranges_tasksis a stub that errors ("Attempt to perform FilteredRead on v1 files") —FilteredReadExecphysically cannot read v1 files. v1 datasets keep bit-for-bit yesterday'sbehavior; TakeExec becomes deletable when v1 read support is dropped. The invariant is
commented at both swap sites.
input plan, not fragment metadata. It IS implementable without column I/O (stream the
input; per batch, count rows whose id is in the live-row set built from fragment metadata +
deletion vectors — per row, so duplicates count); deferred because no plan shape places a
row-stream read under a COUNT today (count plans request no columns, so no read node is
built). Recipe recorded in count_pushdown.rs.
(TakeExec itself sorts and inverse-permutes); duplicates and stable-row-id address order
make request-order reads impossible. The alignment is O(N) per batch and ~2% of query time.
Behavior change
Input rows whose row id/address no longer exists (stale index results pointing at deleted
rows) are now silently dropped on non-stable-row-id datasets, where
TakeExecfailed with arow-count mismatch error. This matches the existing stable-row-id behavior and FTS
deleted-row expectations.
Follow-ups (out of scope, marked in code)
filtered_read_exec_to_protoreturnsnot_supported).NULL
_rowidmemtable rows).cache for many-batch reads.
Testing
row ids, stale-id drops, construction errors,
with_new_children, execution without atokio runtime.
lancelib suite,--features slow_testsquery integration suite (incl. FTSstale-rowid),
lance-namespace-impls; plan-text assertions updated.🤖 Generated with Claude Code