-
Notifications
You must be signed in to change notification settings - Fork 1.2k
perf(core): Implement sparse LiveDocs to reduce memory by up to 8x #15413
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
perf(core): Implement sparse LiveDocs to reduce memory by up to 8x #15413
Conversation
Implements apache#13084 to enable O(deletedDocs) iteration over deleted documents when deletions are sparse. New LiveDocs interface extends Bits with: - liveDocsIterator() for efficient iteration over live docs - deletedDocsIterator() for efficient iteration over deleted docs - deletedCount() for querying deletion density Two implementations: - SparseLiveDocs: Uses SparseFixedBitSet for deleted docs. Optimal for sparse deletions with O(deletedDocs) iteration and ~50% memory savings at 0.1% deletion rate. - DenseLiveDocs: Uses FixedBitSet for live docs. Optimal for dense deletions with traditional O(maxDoc) iteration. Refactoring: Consolidates LiveDocsIterator and DeletedDocsIterator into a single package-private FilteredDocIdSetIterator that uses an IntPredicate to determine which documents to return. This eliminates code duplication between SparseLiveDocs and DenseLiveDocs while providing a clean, functional API. Includes comprehensive unit tests with GIVEN/WHEN/THEN structure. This is runtime-only; file format integration will follow in a subsequent PR.
… iteration This commit adds comprehensive support for efficient iteration over deleted documents through the LiveDocs interface, with automatic selection between sparse and dense implementations based on deletion patterns. Core Implementation: - Integrate sparse LiveDocs into Lucene90LiveDocsFormat with automatic format selection based on deletion rate (uses SparseLiveDocs for < 1% deletions) - Add deletedCount caching in SparseLiveDocs and DenseLiveDocs to eliminate redundant cardinality calculations - Update SegmentReader and LeafReader to expose LiveDocs through the API Performance Characteristics: - SparseLiveDocs: O(k) iteration where k = number of deleted docs - DenseLiveDocs: O(n) iteration where n = total docs - At 1% deletions: 3.5x faster - At 0.1% deletions: 30x faster Test Coverage: - Expand TestLiveDocs with comprehensive edge case validation - Add AssertingLiveDocsFormat - Add AssertingLeafReader Benchmarking: - Add LiveDocsBenchmark with parametrized deletion patterns and rates - Add LiveDocsPathologicalBenchmark for edge case performance validation - Support for multiple deletion patterns (RANDOM, CLUSTERED, SCATTERED) - Configurable deletion rates (0.1% to 10%) and document counts (100K to 50M) This optimization significantly improves performance for indices with sparse deletions, which is the common case in Lucene workloads.
|
This PR does not have an entry in lucene/CHANGES.txt. Consider adding one. If the PR doesn't need a changelog entry, then add the skip-changelog label to it and you will stop receiving this reminder on future updates to the PR. |
c3a2834 to
b403cda
Compare
Use Locale.ROOT in printf calls to avoid forbidden default locale usage
Recognize SparseLiveDocs and DenseLiveDocs as optimized implementations that don't need FilterBits wrapping
8904367 to
1fc7578
Compare
Split printf arguments across multiple lines to comply with formatting rules
System.out is forbidden in benchmark-jmh module. Memory usage information can be analyzed through JMH's standard output and profiling tools instead.
lucene/benchmark-jmh/src/java/org/apache/lucene/benchmark/jmh/LiveDocsBenchmark.java
Show resolved
Hide resolved
Add JMH AuxCounters to track memory usage metrics for sparse and dense LiveDocs implementations. This provides detailed memory statistics in benchmark results without using System.out (which is forbidden). The metrics include actual memory usage and memory overhead ratios for both implementations across all parameter combinations.
Use Locale.ROOT in String.format() calls to avoid platform-dependent formatting. This fixes the forbidden API check failures in CI/CD. - SparseLiveDocs.java: Add Locale.ROOT to String.format() - DenseLiveDocs.java: Add Locale.ROOT to String.format()
890dd41 to
0037442
Compare
Use Locale.ROOT in String.format() calls within test assertions to avoid platform-dependent formatting. This fixes the forbidden API check failures in test code.
Removes SparseLiveBits.java and updates related references: - ScorerUtil.java: Remove unused import - SparseFixedBitSet.java: Remove SparseLiveBits references - AssertingLiveDocsFormat.java: Remove unused import - AssertingLeafReader.java: Remove unused import This class was superseded by the LiveDocs implementations.
Make DenseLiveDocs and SparseLiveDocs final to enforce immutability contract and prevent inheritance. These classes are concrete implementations not designed for extension. Users should implement the LiveDocs interface directly for custom behavior. Rationale: - Protects immutability guarantees documented in javadoc - Prevents cached state (deletedCount) from becoming inconsistent - Enforces interface-based design (extend LiveDocs, not implementations) - Follows Effective Java: design for inheritance or prohibit it - Easier to remove final later than to add it (backwards compatible)
jainankitk
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks @salvatorecampagna for iterating on this quickly. Most of the changes look good to me!
| public Bits readLiveDocs(Directory dir, SegmentCommitInfo info, IOContext context) | ||
| throws IOException { | ||
| long gen = info.getDelGen(); | ||
| String name = IndexFileNames.fileNameFromGeneration(info.info.name, EXTENSION, gen); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We currently return Bits instead of LiveDocs, which makes it difficult to consume. But, I guess there's no other way without updating the codec?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, right. The issue is LiveDocsFormat.readLiveDocs() returns Bits and changing it would break all existing codecs, which feels too big for this PR.
That said, I added LeafReader::getLiveDocsWithDeletedIterator() that returns LiveDocs directly. Callers can also just cast if they want the extra methods:
Bits bits = liveDocsFormat.readLiveDocs(...);
if (bits instanceof LiveDocs liveDocs) {
// Use deletedDocsIterator(), deletedCount(), etc.
}Using LeafReader::getLiveDocsWithDeletedIterator lets consumers opt-in to the optimizations they can't get from just Bits. To me it feels like a reasonable compromise. We avoid breaking changes to existing code while still giving consumers an efficient way to handle both live and deleted documents.
I'm thinking this is better for BWC and API evolution anyway (also considering this is experimental): we can change the return type in a future codec version. For now, Bits works everywhere and LiveDocs is there when you need it.
Happy to file a follow-up issue for changing the return type in a future codec if that makes sense?
| @Override | ||
| public DocIdSetIterator deletedDocsIterator() { | ||
| return new FilteredDocIdSetIterator(maxDoc, deletedCount, doc -> !liveDocs.get(doc)); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I kind of assumed that the DenseLiveDocs should not provide iterator over deleted documents and rather throw an error. Better fail instead of slow iterate using DenseLiveDocs? Maybe you have some use case for this
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see it differently: the goal is twofold - keeping live document iteration efficient (which both formats handle well, and is the common use case) while also making deleted document iteration efficient when possible. Callers should check the cost before iterating deleted docs:
DocIdSetIterator iter = liveDocs.deletedDocsIterator();
long cost = iter.cost();
if (cost < threshold) {
// Sparse: O(deletedDocs) - efficient
} else {
// Dense: O(maxDoc) - skip if you can
}Worth noting: we're never worse than before. The old FixedBitSet approach was always O(maxDoc) for iterating deleted docs - we're keeping that same behavior for dense deletions while making it much better (O(deletedDocs)) for sparse deletions.
I prefer providing it (even if slow) over throwing because:
- Both implementations honor the full
LiveDocsinterface - Slow but correct beats throwing exceptions for debugging/validation
- The javadocs warn about
O(maxDoc)andcost()exposes it - Merge logic, validation tools, etc. benefit from it just working
If we throw, callers would need format-specific handling:
try {
iter = liveDocs.deletedDocsIterator();
} catch (UnsupportedOperationException e) {
// now what?
}The current approach works everywhere - callers check cost if performance matters. Open to changing it if you feel strongly though.
Remove maxDoc and deletionRatePct from secondary metrics as they duplicate values already available in primary benchmark parameters. This reduces noise in JSON output and makes results cleaner.
Scope
For segments with sparse deletions (<=1%), this change tracks only the deleted document IDs instead of maintaining a full bitset of all documents. This simple change reduces LiveDocs memory usage by up to 8x and speeds up deleted-document iteration by 3-4x in typical append-heavy workloads.
The Problem
Lucene currently allocates
maxDoc/8bytes for LiveDocs, independent of the number of deletions. For example, a 10M-document segment always allocates ~1.2 MB even if only 100K documents (1%) are deleted, wasting memory on mostly live documents.This change stores only the deleted document IDs, reducing memory by up to 8x at a 1% deletion rate. The savings scale linearly: for example, a 100M-document segment with 1% deletions drops from ~12 MB to ~800 KB (random pattern).
Common Case
The sparse representation targets the most common real-world scenario: large segments with few deletions. In append-heavy workloads, segments often reach 10M-100M documents with no deletions or only 0.1-1% deletions before merging.
For a 100M-document segment with 0.1% deletions (100K deleted docs):
This memory efficiency is crucial because LiveDocs are held in memory for every open segment. With dozens of segments open simultaneously, the memory savings compound. Additionally, the reduced memory footprint improves cache locality for live document iteration, as we're only storing the small deleted docs bitset rather than a full maxDoc-sized bitset.
The sparse format never makes things worse: it's only selected when deletions are <=1%, where benchmarks confirm consistent wins across all deletion patterns.
How It Works
The implementation uses an adaptive approach:
SparseFixedBitSetFixedBitSetfor live docsThis PR introduces two complementary implementations:
SparseLiveDocsfor low deletion rates andDenseLiveDocsfor high deletion rates, each optimized for their respective cases. These implementations add efficient iteration methods via theLiveDocsinterface:deletedDocsIterator()for O(deletedDocs) iteration andliveDocsIterator()for O(liveDocs) iteration.The
LiveDocsinterface approach allows the test framework to wrap these implementations withAssertingLiveDocs, which validates correctness during testing by delegating to the underlyingLiveDocsmethods while adding assertions. This preserved compatibility with existing test infrastructure without requiring changes to test code.The codec automatically selects the right format when reading
.livfiles from disk. Benchmarks show the crossover point, where sparse and dense performance equalize, occurs around 5-10% depending on deletion pattern. By choosing 1%, sparse provides clear wins in both memory and iteration speed across all patterns. Even in the worst-case (uniform) distribution, sparse remains faster at <=1%. This conservative threshold guarantees predictable behavior while targeting the most common case where sparse representations excel.This PR also introduces a
LiveDocsinterface with a new methodLeafReader.getLiveDocsWithDeletedIterator()that enables efficient O(deletedDocs) iteration viadeletedDocsIterator(). Consumers can check the iterator'scost()method to determine whether iterating deleted docs would be beneficial for their use case. Rather than replacing the existinggetLiveDocs()API (which would require extensive changes across the codebase), this approach lets consumers opt-in to the optimization when they need it. Use cases likePointTreeBulkCollector(histogram correction, #15226) can now efficiently iterate only deleted documents to adjust their counts, while existing code continues to work unchanged.Benchmark Results
10M document segment:
Random pattern (typical real-world scenario)
Clustered pattern (best case for sparse)
Uniform pattern (worst case for sparse)
Why the conservative 1% threshold? These benchmarks show significant pattern-dependent behavior:
By choosing 1%, sparse is used only when it generally delivers clear memory wins across most deletion patterns and never performs catastrophically worse. At <= 1%, sparse provides up to 1.6-40x memory reduction and 3-40x iteration speedup across typical or best-case deletion distributions.
Pathological case: maximally scattered deletions
This PR also tested a worst-case scenario with deletions maximally scattered across the bitset (1.5625% deletion rate):
Even in this unfavorable scenario, where sparse uses 5-6% more memory, the iteration speedup remains stable at ~4x across all segment sizes. This shows that the overhead remains bounded and predictable, making it an acceptable trade-off for iteration-heavy workloads.
Backward Compatibility
This change introduces no breaking changes:
.livfiles are fully compatible)BitsAPI continues to function as beforeNote on disk format: This PR keeps the existing Lucene90
.livformat (dense bitset on disk) to minimize changes and maintain compatibility. When reading,Lucene90LiveDocsFormatconverts the on-disk dense representation to the in-memory sparse representation for segments with <=1% deletions. This conversion has negligible overhead since it only happens when segments are loaded, not during queries. A follow-up PR could introduce a new codec version that writes sparse deletions natively to disk, eliminating the conversion step entirely and reducing disk space for .liv files.Fixes #13084