Skip to content

perf(index): coalesce btree page loads into one read_ranges request - #9310

Open
westonpace wants to merge 2 commits into
lance-format:mainfrom
westonpace:perf/btree-coalesce-page-loads
Open

westonpace wants to merge 2 commits into
lance-format:mainfrom
westonpace:perf/btree-coalesce-page-loads

Conversation

@westonpace

Copy link
Copy Markdown
Member

A btree search loaded each page it needed with its own read, so a range predicate spanning many pages cost one request per page: a filter matching 7M of 10M rows touches 1,710 pages and issued 3,764 reads, with the index lookup accounting for 304ms of the query's 306ms.

Decide the whole page set before loading any of it instead, and hand the pages that are not already cached to a single read_ranges call, which already merges adjacent and nearby ranges into shared requests.

Adds IndexReader::read_record_batches, whose default implementation keeps today's one-request-per-batch behaviour, so the legacy V1 index reader (whose batches are row groups rather than fixed row ranges) is unaffected. The current-format reader overrides it to map batch numbers onto row ranges, and the ranged reader groups pages by the file that holds them so range- partitioned indices coalesce per file rather than falling back to an unbounded per-page read.

The batched read stays behind the page cache's loader, so concurrent queries still coalesce against each other, and a page the batch turns out not to cover falls back to a single-page read.

Measured on a 10M row uint64 btree, cold index cache, filter matching 7M rows:

read_iops 3,764 -> 68
read_bytes 50.6MB -> 49.9MB
search_time 304ms -> 207ms
allocations 1,132,917 -> 277,796

parts_loaded is unchanged at 1,710: the same pages are materialized, in far fewer requests. Peak memory rises 186MB -> 257MB on the index-only query, because the merged batch is alive while each page is copied into its own FlatIndex; chunking the read would bound that at the cost of a few more requests.

Also adds a large_range case to the basic btree search benchmark, which had no filter selecting more than 8 of its 10M rows.

westonpace and others added 2 commits September 16, 2026 12:08
A btree search loaded each page it needed with its own read, so a range
predicate spanning many pages cost one request per page: a filter matching
7M of 10M rows touches 1,710 pages and issued 3,764 reads, with the index
lookup accounting for 304ms of the query's 306ms.

Decide the whole page set before loading any of it instead, and hand the
pages that are not already cached to a single `read_ranges` call, which
already merges adjacent and nearby ranges into shared requests.

Adds `IndexReader::read_record_batches`, whose default implementation keeps
today's one-request-per-batch behaviour, so the legacy V1 index reader (whose
batches are row groups rather than fixed row ranges) is unaffected. The
current-format reader overrides it to map batch numbers onto row ranges, and
the ranged reader groups pages by the file that holds them so range-
partitioned indices coalesce per file rather than falling back to an
unbounded per-page read.

The batched read stays behind the page cache's loader, so concurrent queries
still coalesce against each other, and a page the batch turns out not to
cover falls back to a single-page read.

Measured on a 10M row uint64 btree, cold index cache, filter matching 7M rows:

  read_iops     3,764 -> 68
  read_bytes    50.6MB -> 49.9MB
  search_time   304ms -> 207ms
  allocations   1,132,917 -> 277,796

`parts_loaded` is unchanged at 1,710: the same pages are materialized, in far
fewer requests. Peak memory rises 186MB -> 257MB on the index-only query,
because the merged batch is alive while each page is copied into its own
FlatIndex; chunking the read would bound that at the cost of a few more
requests.

Also adds a `large_range` case to the basic btree search benchmark, which had
no filter selecting more than 8 of its 10M rows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions github-actions Bot added A-python Python bindings A-index Vector index, linalg, tokenizer performance labels Sep 16, 2026

@lance-gatekeeper lance-gatekeeper Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gate recommendation: request changes.

Coalescing adjacent B-tree page reads is worthwhile, but it needs a bounded working set. Please read and materialize pages in byte- or page-bounded chunks so wide predicates retain the I/O reduction without making peak memory and request concurrency scale with the entire selected index.

.map(|page_number| *page_number as u64)
.collect::<Vec<_>>();
let batches = reader
.read_record_batches(&batch_numbers, self.batch_size)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This query-wide call removes the old resource bound: peak memory and in-flight I/O now scale with every cold page selected. For current-format files, read_record_batches merges all requested row ranges and read_ranges collects them before this OnceCell<HashMap<...>> retains every slice until the search finishes, outside the configured index-cache capacity. A wide predicate over the billion-row scale explicitly contemplated by BTreeIndex can therefore allocate many GiB and terminate the query or process; retrying repeats the same allocation. Legacy V1 also enters the default try_join_all for the whole page set, and range-partitioned indices open every touched file concurrently, so those paths lose the previous buffered(get_num_compute_intensive_cpus()) bound too. Please split missing pages into a fixed byte/page budget and process and drop each chunk before advancing; that preserves range coalescing while bounding memory and in-flight reads.

Bounded reproducer

Temporarily add eprintln!("gate_repro_batch_pages={}", batch_numbers.len()); after this call, then run:

cargo test -p lance-index test_range_query_coalesces_page_reads -- --nocapture

On this head the 10,000-row fixture prints gate_repro_batch_pages=141: every selected page is handed to one batch. The fixture is intentionally too small to exhaust memory, but it directly verifies the query-sized retention mechanism above.

@lance-gatekeeper lance-gatekeeper Bot added the K-changes Latest Gatekeeper recommendation requests changes. label Sep 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-index Vector index, linalg, tokenizer A-python Python bindings K-changes Latest Gatekeeper recommendation requests changes. performance

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant