perf(table): fold clustered deletions into range segments - #9311
dshepelev15 wants to merge 1 commit into
Conversation
Xuanwo
left a comment
There was a problem hiding this comment.
Thanks for taking the #9272 discussion the rest of the way — this is the right follow-up. Clustered deletions after compact really do make the bitmaps dominate, and writing ordinary Range segments gets the same saving without a format change. I’m good with the direction, including keeping it opt-in.
A few small cleanups, none of them blocking:
-
Could
RANGE_SEGMENTS_CONFIG_KEYrustdoc mention that turning the key off stops future conversions but does not rewrite already-converted fragments? That’s easy to miss operationally. -
HoleRunsispub used today. If it’s only the payload behindU64Segment::Ranges, it might be happier as crate-private. -
The standalone
Ranges→SortedArrayarm isn’t on the sequence write path. I think we can drop it rather than keep a third encoding around.
efcf4dc to
4609b0f
Compare
|
Thanks for the review! All three addressed in the latest push:
Commit message and description updated to match. |
4609b0f to
c9e4de0
Compare
|
Rebased on |
A stable-row-id table whose deletions cluster, as they do after compacting
fragments with deleted rows, ends up with `RangeWithBitmap` segments that
spend one bit on every row of the range. On one large table that was
890 MB of a 945 MB manifest for 7.1 billion rows whose holes form only
26 million runs.
Writing such a sequence as plain `Range` segments, one per run of live
rows, cuts the manifest to 323 MB and needs no format change: every
version reads `Range` segments. What makes many `Range` segments cheap to
read is the in-memory side. The decoder folds each run of consecutive,
sorted, disjoint `Range` segments into one `U64Segment::Ranges`, backed by
`HoleRuns`: the range starts and a prefix sum of present rows in `u32`, so
position and offset lookups are binary searches and the whole run costs
8 bytes per range instead of a heap-allocated enum each. The decoder also
walks the `RowIdSequence` wire format directly, so tens of millions of
`Range` segments do not each become a proto message before folding.
Encoding expands the segment back into `Range` segments; it is only
ever written as part of a sequence.
Re-encoding existing bitmaps is opt-in through the table config key
`lance.row_ids.range_segments` (set with `update_config`): the enabling
commit re-encodes every fragment where ranges are smaller, later commits
re-encode what they change. Older readers understand the result but,
lacking the compact form, handle thousands of segments per fragment
slowly, which is why it is not the default.
Benchmark on the manifest of that table (20,760 fragments, 9,734 bitmap
segments, 8,094 of them converted), decoded locally:
manifest file: 945.0 MB -> 322.7 MB
inline row id bytes: 927.3 MB -> 305.0 MB
manifest decode, warm: 206-246 ms -> 148-197 ms
RSS after decode: 995 MB -> 395 MB
sequences decode + RowIdIndex: 222-243 ms -> 248-285 ms
RSS after index: 1041 MB -> 465 MB
c9e4de0 to
af41f81
Compare
|
One more pass to trim the diff (−99 lines, no behaviour change on the write path):
|
There was a problem hiding this comment.
Consolidating the fold path and narrowing re-encoding to bitmap-backed segments preserve the reviewed opt-in behavior and existing wire format. The remaining operational risk is persistent old-reader slowdown: disabling lance.row_ids.range_segments stops future conversions but does not rewrite unchanged fragments, so coordinate reader upgrades before enabling it.
Please mark this PR with the breaking-change label.
A stable-row-id table whose deletions cluster, as they do after compacting
fragments with deleted rows, ends up with
RangeWithBitmapsegments thatspend one bit on every row of the range. On one large table that was
890 MB of a 945 MB manifest for 7.1 billion rows whose holes form only
26 million runs.
Writing such a sequence as plain
Rangesegments, one per run of liverows, cuts the manifest to 323 MB and needs no format change: every
version reads
Rangesegments. What makes manyRangesegments cheap toread is the in-memory side. The decoder folds each run of consecutive,
sorted, disjoint
Rangesegments into oneU64Segment::Ranges, backed byHoleRuns: the range starts and a prefix sum of present rows inu32, soposition and offset lookups are binary searches and the whole run costs
8 bytes per range instead of a heap-allocated enum each. The decoder also
walks the
RowIdSequencewire format directly, so tens of millions ofRangesegments do not each become a proto message before folding.Encoding expands the segment back into
Rangesegments; it is onlyever written as part of a sequence.
Re-encoding existing bitmaps is opt-in through the table config key
lance.row_ids.range_segments(set withupdate_config): the enablingcommit re-encodes every fragment where ranges are smaller, later commits
re-encode what they change. Older readers understand the result but,
lacking the compact form, handle thousands of segments per fragment
slowly, which is why it is not the default.
Benchmark on the manifest of that table (20,760 fragments, 9,734 bitmap
segments, 8,094 of them converted), decoded locally:
Follows up on the discussion in #9272: the same saving with the existing wire format instead of a new segment type.