perf(index): coalesce btree page loads into one read_ranges request - #9310
westonpace wants to merge 2 commits into
Conversation
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>
There was a problem hiding this comment.
❌ 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) |
There was a problem hiding this comment.
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 -- --nocaptureOn 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.
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_rangescall, 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_loadedis 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_rangecase to the basic btree search benchmark, which had no filter selecting more than 8 of its 10M rows.