Skip to content

perf(rowids): probe the fragments instead of merging every row id - #8624

Open
pengw0048 wants to merge 9 commits into
lance-format:mainfrom
pengw0048:peng/rowid-index-lazy
Open

perf(rowids): probe the fragments instead of merging every row id#8624
pengw0048 wants to merge 9 commits into
lance-format:mainfrom
pengw0048:peng/rowid-index-lazy

Conversation

@pengw0048

@pengw0048 pengw0048 commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Problem

RowIdIndex::new reads every row id of every fragment whose sequence is not a
plain Range. decompose_sequence has a constant-time path for
U64Segment::Range without deletions; every other encoding, and every fragment
that carries a deletion file, materializes one (row_id, address) pair per row,
sorts them, and re-encodes two segments. get_row_id_index builds the index for
the whole dataset, so a lookup of one row id pays for the whole table.

A delete puts a table on that path and keeps it there. While the deletion files
exist, the build checks the deletion vector row by row. After a compaction
materializes those deletions, the ids inside each fragment are no longer
contiguous, so the build decodes and re-encodes every row id instead.

The cost repeats: the index weighs about as much as the sequences it copies, so
on a large table it does not fit DEFAULT_METADATA_CACHE_SIZE, and the next
lookup builds it again.

Change

new picks one of two representations and never changes it.

  • The merged map is what it builds today, and it stays the default: a
    Range-only sequence decomposes in constant time, and a small table is cheap
    to read whatever its encoding.
  • A probe answers from one entry per fragment — the Arc<RowIdSequence>,
    the deletion vector, and each segment's row id range plus its physical start
    offset. new reads those bounds instead of every row id. A lookup binary-
    searches the fragments by their lowest row id, descends a max-end heap so a
    fragment out of reach of the id costs nothing, and asks the covering segment
    for position.

new probes only when both hold: the merged build would read more than
MERGE_ROWS_BUDGET row ids, and no more than MAX_PROBE_DEPTH fragments cover
any one id. So wherever the merged map reads faster, it is also the one new
builds.

An unsorted U64Segment::Array gets a sorted lookup table, built with the entry
and charged with it, because its own position scans. build_chunk_from_pairs
sorts its pairs for the same reason, which makes a merged chunk searchable too.

Measurements

Release build, 96-core Linux x86-64. build is RowIdIndex::new; the lookup
columns are 100,000 random live ids.

shape build get get_many
1,000 fragments, 20M sparse ids, 2 fragments per id 0.22 ms vs 485 ms 236 ns vs 23 µs 175 ns vs 24 µs
4,000 fragments, 4M ids, every fragment covers every id 104.7 ms vs 101.5 ms 14 ns vs 13 ns 21 ns vs 18 ns
1,000 fragments, 20M contiguous ids, disjoint 0.23 ms vs 0.19 ms 36 ns vs 36 ns 16 ns vs 14 ns
1 fragment, 2M descending ids (unsorted Array) 7.6 ms vs 8.9 ms 203 ns vs 2 ms 55 ns vs 2 ms

Row 1 is the shape a compacted table has, and the merged map is slower to build
and slower to read there: merging the overlap produces one wide sparse segment
whose position counts bits up to the offset.

Row 2 is where the merged map earns its build, and new builds it, so both
columns match.

Row 4 is the finding about unsorted arrays, in both representations.

On a real table

A dataset of 15,431,187,521 rows in 17,601 fragments, holding 3.36 GB of row id
sequences. Half of the segments are RangeWithBitmap or RangeWithHoles, which
is what a compaction leaves behind once it materializes a long run of deletes.
46 fragments cover the most crowded row id. _take_rows of one column, in a
fresh process:

before after
1 row id, first call ~154 s 1.92 s
1 row id, second call ~127 s 1.10 s
200 scattered row ids, first call 161.2 s 9.00 s

(Measured on the earlier revision of this PR; the representation it picks for
this table is the same one, and #8642 takes the 1.10 s to 0.61 s.)

The integrity boundary

new no longer materializes ids on the probe path, so it cannot compare id sets
the way merge_overlapping_chunks does. The check moved to the lookup, where it
is free: a probe already visits every candidate fragment rather than stopping at
the first hit, and a second live match returns the same corrupt-index error the
merged build raises. get and get_many are fallible for this, and every
production caller propagates the error. validate_stable_row_ids remains the
complete offline check.

Checking at construction instead is possible but not cheap on the shape this PR
is for: proving overlapping id sets disjoint without reading them means
intersecting the sequences bit by bit, on the order of 10^10 word operations at
depth 46 over that table's id space, paid again on every process that opens the
dataset. The fallible lookup keeps the boundary without that cost.

Closes #8621

…e index

RowIdIndex::new walked every row id of every fragment whose sequence is
not a plain Range, and get_row_id_index builds the index for the whole
dataset, so one point lookup paid for the whole table. Keep one entry per
fragment with per-segment bounds instead, and resolve on lookup.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Peng Wang <peng.wang@lumalabs.ai>
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-changes Latest Gatekeeper recommendation requests changes. label Aug 18, 2026
@pengw0048
pengw0048 marked this pull request as draft August 18, 2026 22:29
pengw0048 and others added 3 commits August 18, 2026 18:37
…eement

Build a row-id to position map for U64Segment::Array, the one encoding whose
position() walks the segment. Resolve get_many through get so the two APIs
return the same address for a row id that two fragments claim. Charge the
sequences, deletion vectors and position maps the index retains through its
Arcs, so a cache that weighs the entry bounds what the entry keeps alive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Peng Wang <peng.wang@lumalabs.ai>
…rlap

new() checks again that no row id is live in two fragments, so the integrity
boundary stays where it was. Two fragments can only claim an id where their
row id ranges intersect, so the check walks the intersecting pairs and only
the ids inside each intersection. Drops verify(), which no caller reached.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Peng Wang <peng.wang@lumalabs.ai>
A prefix maximum cannot stop a backward walk once one fragment covers a wide
range, so a miss walked every fragment whose start sits below the id: 4.46 us
per miss at 16,384 fragments against 2 ns before this branch. Descend a
max-end tree instead, which holds at 233 ns as the fragment count grows.

Fragments whose ranges interleave produce a quadratic number of intersecting
pairs, and walking their ids cost 1.61 s at 2,048 fragments. Estimate that
work first and skip it above a budget; validate() runs it on demand. Fill the
array position table on first use so a table nobody looks up costs nothing,
which returns deep_size_of to what it was before the table existed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Peng Wang <peng.wang@lumalabs.ai>
@pengw0048
pengw0048 marked this pull request as ready for review August 19, 2026 17:01
…cheap

Keep the merged map for the shapes it serves well, and answer from a
per-fragment probe otherwise. `new` reads each segment's bounds instead
of every row id, and builds the merged map when that build is cheap or
once probes have paid for it.
@pengw0048
pengw0048 marked this pull request as draft August 19, 2026 20:14
@pengw0048 pengw0048 changed the title perf(rowids): resolve row ids per fragment instead of building a dense index perf(rowids): probe the fragments instead of merging every row id Aug 19, 2026
@lance-gatekeeper lance-gatekeeper Bot removed the K-changes Latest Gatekeeper recommendation requests changes. label Aug 19, 2026
@pengw0048
pengw0048 marked this pull request as ready for review August 20, 2026 14:45
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-changes Latest Gatekeeper recommendation requests changes. label Aug 20, 2026
Three review findings, all of them about the lazy build:

- The representation is now chosen by `new` and never changes, so a cached
  entry cannot grow after its weight is fixed. The lazy upgrade was
  solving a case that does not exist: wherever the merged map reads faster
  it is also the one `new` builds, and where it does not, probing wins on
  both build and lookup.
- An unsorted `Array` segment gets a sorted lookup table, built with the
  entry and charged with it, so a probe binary-searches instead of
  scanning. `build_chunk_from_pairs` sorts its pairs for the same reason,
  which also makes a merged chunk searchable.
- A probe visits every candidate instead of stopping at the first hit, so
  a row id live in two fragments resolves the same way through `get` and
  `get_many`, and trips an assertion in a debug build.
@lance-gatekeeper lance-gatekeeper Bot removed the K-changes Latest Gatekeeper recommendation requests changes. label Aug 21, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-changes Latest Gatekeeper recommendation requests changes. label Aug 21, 2026
…agments

The probe path visits every candidate fragment, so the duplicate check is
free; it now returns the same corrupt-index error the merged build raises,
instead of a debug assertion. get and get_many are fallible and every
production caller propagates the error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lance-gatekeeper lance-gatekeeper Bot removed the K-changes Latest Gatekeeper recommendation requests changes. label Aug 21, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-approved Latest Gatekeeper recommendation permits acceptance. label Aug 21, 2026
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lance-gatekeeper lance-gatekeeper Bot removed the K-approved Latest Gatekeeper recommendation permits acceptance. label Aug 21, 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: approve.

This revision documents the fallible lookup contract with explicit error sections and leaves the verified probe behavior unchanged: both lookup APIs still fail closed when a requested stable ID is live in multiple fragments.

Please mark this PR with the breaking-change label.

@lance-gatekeeper lance-gatekeeper Bot added the K-approved Latest Gatekeeper recommendation permits acceptance. label Aug 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

K-approved Latest Gatekeeper recommendation permits acceptance. performance

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Point lookup by stable row id costs ~2 minutes of CPU when row-id sequences hold holes

1 participant