From 87fef1dbcd4da34739a5f33a5ac6cd29148929ea Mon Sep 17 00:00:00 2001 From: Chengxi Shi Date: Tue, 14 Jul 2026 10:51:48 -0700 Subject: [PATCH 1/6] Bulk nextDocsAndScores for constant-score disjunction clauses MaxScoreBulkScorer's term-at-a-time scoring drains clauses through Scorer#nextDocsAndScores. TermScorer has a bulk implementation over postings, but ConstantScoreScorer falls back to one nextDoc() call per match. When the underlying iterator is a disjunction, every such call pays a DisiPriorityQueue update, and with tied constant scores the top-k threshold cannot prune, so the full candidate stream pays it. Drain a 4096-doc window in bulk via DocIdSetIterator#intoBitSet when the underlying iterator is a DisjunctionDISIApproximation; keep the doc-at-a-time loop for iterators that are cheap to advance and for two-phase iterators. --- .../lucene/search/ConstantScoreScorer.java | 80 +++++++++++++++++-- 1 file changed, 73 insertions(+), 7 deletions(-) diff --git a/lucene/core/src/java/org/apache/lucene/search/ConstantScoreScorer.java b/lucene/core/src/java/org/apache/lucene/search/ConstantScoreScorer.java index e428191c4130..5f7f998c4a58 100644 --- a/lucene/core/src/java/org/apache/lucene/search/ConstantScoreScorer.java +++ b/lucene/core/src/java/org/apache/lucene/search/ConstantScoreScorer.java @@ -58,6 +58,13 @@ public long cost() { @Override public void intoBitSet(int upTo, FixedBitSet bitSet, int offset) throws IOException { + if (doc != delegate.docID()) { + // The delegate was swapped for an empty iterator (see setMinCompetitiveScore); the + // default implementation terminates via nextDoc() without touching the stale delegate + // position. + super.intoBitSet(upTo, bitSet, offset); + return; + } delegate.intoBitSet(upTo, bitSet, offset); doc = delegate.docID(); } @@ -68,6 +75,10 @@ public void intoBitSet(int upTo, FixedBitSet bitSet, int offset) throws IOExcept private final DocIdSetIterator approximation; private final TwoPhaseIterator twoPhaseIterator; private final DocIdSetIterator disi; + // Whether the underlying iterator pays per-nextDoc() overhead that the bulk + // #nextDocsAndScores path amortizes. Iterators that are cheap to advance one doc at a time + // (single postings list, bit set) are better served by the plain doc-at-a-time loop. + private final boolean bulkDrainWorthwhile; /** * Constructor based on a {@link DocIdSetIterator} which will be used to drive iteration. Two @@ -86,6 +97,7 @@ public ConstantScoreScorer(float score, ScoreMode scoreMode, DocIdSetIterator di scoreMode == ScoreMode.TOP_SCORES ? new DocIdSetIteratorWrapper(disi) : disi; this.twoPhaseIterator = null; this.disi = this.approximation; + this.bulkDrainWorthwhile = disi instanceof DisjunctionDISIApproximation; } /** @@ -120,6 +132,7 @@ public float matchCost() { this.twoPhaseIterator = twoPhaseIterator; } this.disi = TwoPhaseIterator.asDocIdSetIterator(this.twoPhaseIterator); + this.bulkDrainWorthwhile = false; // two-phase matches must be verified one by one } @Override @@ -154,18 +167,71 @@ public float score() throws IOException { return score; } + // Doc-ID window covered by one bulk #nextDocsAndScores fill. Matches + // MaxScoreBulkScorer.INNER_WINDOW_SIZE and DenseConjunctionBulkScorer.WINDOW_SIZE, so a single + // fill covers a full inner scoring window with a bit set that stays core-cache resident. + private static final int BULK_WINDOW_SIZE = 4096; + + private FixedBitSet bulkWindowMatches; // lazily allocated + @Override public void nextDocsAndScores(int upTo, Bits liveDocs, DocAndFloatFeatureBuffer buffer) throws IOException { - int batchSize = 64; - buffer.growNoCopy(batchSize); - int size = 0; + if (bulkDrainWorthwhile == false) { + // Either matches must be verified one by one (two-phase), or the iterator is cheap to + // advance one doc at a time (single postings list, bit set) and the per-window fixed cost + // of the bulk path (bit set clear/flatten) would not pay for itself. Only heap-based + // composite iterators (disjunctions), which pay a priority-queue update per nextDoc(), + // benefit from the bulk drain. + int batchSize = 64; + buffer.growNoCopy(batchSize); + int size = 0; + DocIdSetIterator iterator = iterator(); + for (int doc = iterator.docID(); doc < upTo && size < batchSize; doc = iterator.nextDoc()) { + if (liveDocs == null || liveDocs.get(doc)) { + buffer.docs[size] = doc; + ++size; + } + } + Arrays.fill(buffer.features, 0, size, score); + buffer.size = size; + return; + } + + // Drain a window of matches in bulk via DocIdSetIterator#intoBitSet. Disjunctions implement + // it with one bulk load per sub-iterator, which is much cheaper than paying a priority-queue + // update per nextDoc() call. This matters for constant-score disjunction clauses under top-k + // scoring (MaxScoreBulkScorer), which have no impact-based bulk path. + buffer.size = 0; DocIdSetIterator iterator = iterator(); - for (int doc = iterator.docID(); doc < upTo && size < batchSize; doc = iterator.nextDoc()) { - if (liveDocs == null || liveDocs.get(doc)) { - buffer.docs[size] = doc; - ++size; + int doc = iterator.docID(); + if (doc >= upTo) { + return; + } + if (bulkWindowMatches == null) { + bulkWindowMatches = new FixedBitSet(BULK_WINDOW_SIZE); + } else { + bulkWindowMatches.clear(); + } + int windowMax = (int) Math.min(upTo, (long) doc + BULK_WINDOW_SIZE); + iterator.intoBitSet(windowMax, bulkWindowMatches, doc); + int cardinality = bulkWindowMatches.cardinality(); + if (cardinality == 0) { + // No match in this window; the iterator already advanced to windowMax or beyond, the + // caller re-invokes for the next window. + return; + } + buffer.growNoCopy(cardinality); + int size = bulkWindowMatches.intoArray(0, windowMax - doc, doc, buffer.docs); + if (liveDocs != null) { + int kept = 0; + for (int i = 0; i < size; ++i) { + int d = buffer.docs[i]; + if (liveDocs.get(d)) { + buffer.docs[kept++] = d; + } } + size = kept; } Arrays.fill(buffer.features, 0, size, score); buffer.size = size; From 5f4151b4ade9caf73d9a743a5fe3ccf6e6ba1885 Mon Sep 17 00:00:00 2001 From: Chengxi Shi Date: Tue, 14 Jul 2026 10:53:50 -0700 Subject: [PATCH 2/6] Add CHANGES entry --- lucene/CHANGES.txt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lucene/CHANGES.txt b/lucene/CHANGES.txt index 4964b3ae7ae7..b8a3e3658140 100644 --- a/lucene/CHANGES.txt +++ b/lucene/CHANGES.txt @@ -338,6 +338,12 @@ Improvements Optimizations --------------------- +* GITHUB#16394: ConstantScoreScorer#nextDocsAndScores now drains disjunction-backed iterators in + bulk via DocIdSetIterator#intoBitSet instead of one nextDoc() call per match, avoiding a + priority-queue update per matching doc under MaxScoreBulkScorer's term-at-a-time scoring. + Iterators that are cheap to advance one doc at a time keep the doc-at-a-time loop. + (Chengxi Shi) + * GITHUG#16280: Single-pass writeString fast path for short strings in ByteBuffersDataOutput (neoremind) * GITHUB#16146: Speed up no-score filtered-optional Boolean queries by routing dense conjunctions with cached FixedBitSet filters From 38dea2f27420598ef51795cd372c4e6685499466 Mon Sep 17 00:00:00 2001 From: Chengxi Shi Date: Mon, 27 Jul 2026 07:58:29 -0700 Subject: [PATCH 3/6] Add DocIdSetIterator#intoArray with a bulk implementation for disjunctions The default implementation copies doc IDs one nextDoc() call at a time. DisjunctionDISIApproximation overrides it to load a window of doc IDs through #intoBitSet, which costs one bulk load and one priority-queue update per sub-iterator instead of one priority-queue update per matching doc. --- .../search/DisjunctionDISIApproximation.java | 32 +++++++ .../lucene/search/DocIdSetIterator.java | 33 +++++++ .../TestDisjunctionDISIApproximation.java | 86 +++++++++++++++++++ .../lucene/search/TestDocIdSetIterator.java | 26 ++++++ 4 files changed, 177 insertions(+) diff --git a/lucene/core/src/java/org/apache/lucene/search/DisjunctionDISIApproximation.java b/lucene/core/src/java/org/apache/lucene/search/DisjunctionDISIApproximation.java index ef5b1bbbff97..c3bba2746c54 100644 --- a/lucene/core/src/java/org/apache/lucene/search/DisjunctionDISIApproximation.java +++ b/lucene/core/src/java/org/apache/lucene/search/DisjunctionDISIApproximation.java @@ -31,6 +31,11 @@ */ public final class DisjunctionDISIApproximation extends AbstractDocIdSetIterator { + // Doc ID window that a single #intoArray call loads at a time. It matches + // MaxScoreBulkScorer#INNER_WINDOW_SIZE and DenseConjunctionBulkScorer#WINDOW_SIZE so that a + // single window covers a full scoring window, with a bit set that stays cache-resident. + private static final int WINDOW_SIZE = 4096; + public static DisjunctionDISIApproximation of( Collection subIterators, long leadCost) { @@ -44,6 +49,7 @@ public static DisjunctionDISIApproximation of( private final long cost; private DisiWrapper leadTop; private int minOtherDoc; + private FixedBitSet window; // lazily allocated by #intoArray public DisjunctionDISIApproximation( Collection subIterators, long leadCost) { @@ -161,6 +167,32 @@ public void intoBitSet(int upTo, FixedBitSet bitSet, int offset) throws IOExcept doc = Math.min(leadTop.doc, minOtherDoc); } + @Override + public int intoArray(int upTo, int[] docs) throws IOException { + assert docs.length > 0; + // Loading a window of doc IDs through #intoBitSet performs one bulk load and one heap update + // per sub-iterator, while advancing one doc at a time performs a heap update per matching doc. + // The current doc always falls into the first window, so the loop below normally runs a single + // iteration. It only guards against a window that yields no doc, which callers would read as + // "no doc left below upTo". + int windowSize = Math.min(docs.length, WINDOW_SIZE); + while (doc < upTo) { + if (window == null) { + window = new FixedBitSet(WINDOW_SIZE); + } else { + window.clear(); + } + int windowMin = doc; + int windowMax = (int) Math.min(upTo, (long) windowMin + windowSize); + intoBitSet(windowMax, window, windowMin); + int size = window.intoArray(0, windowMax - windowMin, windowMin, docs); + if (size != 0) { + return size; + } + } + return 0; + } + /** Return the linked list of iterators positioned on the current doc. */ public DisiWrapper topList() { if (leadTop.doc < minOtherDoc) { diff --git a/lucene/core/src/java/org/apache/lucene/search/DocIdSetIterator.java b/lucene/core/src/java/org/apache/lucene/search/DocIdSetIterator.java index 9443985c20fd..aa6e3331ecbb 100644 --- a/lucene/core/src/java/org/apache/lucene/search/DocIdSetIterator.java +++ b/lucene/core/src/java/org/apache/lucene/search/DocIdSetIterator.java @@ -209,6 +209,39 @@ public void intoBitSet(int upTo, FixedBitSet bitSet, int offset) throws IOExcept } } + /** + * Copy doc IDs of this iterator into the given array, starting at the current {@link #docID()} + * and stopping before {@code upTo}, and return the number of copied doc IDs. At most {@code + * docs.length} doc IDs are copied, so callers must call this method repeatedly in order to + * consume all doc IDs below {@code upTo}. Upon return, this iterator is positioned on the first + * doc ID that has not been copied. + * + *

A return value of {@code 0} means that this iterator has no doc ID left below {@code upTo}. + * Implementations must never return {@code 0} while doc IDs below {@code upTo} remain. + * + *

This should behave exactly as if implemented as below, which is the default implementation: + * + *


+   * int size = 0;
+   * for (int doc = docID(); doc < upTo && size < docs.length; doc = nextDoc()) {
+   *   docs[size++] = doc;
+   * }
+   * return size;
+   * 
+ * + *

Note: The given array must not be empty. Behaviour is undefined if this iterator is + * unpositioned. + * + * @lucene.internal + */ + public int intoArray(int upTo, int[] docs) throws IOException { + int size = 0; + for (int doc = docID(); doc < upTo && size < docs.length; doc = nextDoc()) { + docs[size++] = doc; + } + return size; + } + /** * Returns the end of the run of consecutive doc IDs that match this {@link DocIdSetIterator} and * that contains the current {@link #docID()}, that is: one plus the last doc ID of the run. diff --git a/lucene/core/src/test/org/apache/lucene/search/TestDisjunctionDISIApproximation.java b/lucene/core/src/test/org/apache/lucene/search/TestDisjunctionDISIApproximation.java index 8f17a19187d9..96aea5387cb3 100644 --- a/lucene/core/src/test/org/apache/lucene/search/TestDisjunctionDISIApproximation.java +++ b/lucene/core/src/test/org/apache/lucene/search/TestDisjunctionDISIApproximation.java @@ -17,8 +17,11 @@ package org.apache.lucene.search; import java.io.IOException; +import java.util.ArrayList; import java.util.Arrays; +import java.util.List; import org.apache.lucene.tests.util.LuceneTestCase; +import org.apache.lucene.tests.util.TestUtil; public class TestDisjunctionDISIApproximation extends LuceneTestCase { @@ -64,6 +67,89 @@ public void testDocIDRunEndOnlyUsesLeadIteratorsWhenBothClausesMatchAndOtherHasL assertEquals(110, iterator.docIDRunEnd()); } + public void testIntoArray() throws IOException { + DisjunctionDISIApproximation iterator = + disjunction(100_000, DocIdSetIterator.range(10, 20), DocIdSetIterator.range(15, 30)); + assertEquals(10, iterator.nextDoc()); + + // The bulk window is bounded by the length of the target array, so that no match is dropped. + int[] docs = new int[8]; + assertEquals(8, iterator.intoArray(DocIdSetIterator.NO_MORE_DOCS, docs)); + assertArrayEquals(new int[] {10, 11, 12, 13, 14, 15, 16, 17}, docs); + assertEquals(18, iterator.docID()); + + docs = new int[64]; + assertEquals(12, iterator.intoArray(DocIdSetIterator.NO_MORE_DOCS, docs)); + for (int i = 0; i < 12; ++i) { + assertEquals(18 + i, docs[i]); + } + assertEquals(DocIdSetIterator.NO_MORE_DOCS, iterator.docID()); + assertEquals(0, iterator.intoArray(DocIdSetIterator.NO_MORE_DOCS, docs)); + } + + public void testIntoArrayReturnsSameDocsAsNextDoc() throws IOException { + for (int iter = 0; iter < 100; ++iter) { + int maxDoc = TestUtil.nextInt(random(), 1, 20_000); + int numClauses = TestUtil.nextInt(random(), 2, 5); + int[][] ranges = new int[numClauses][]; + for (int i = 0; i < numClauses; ++i) { + int min = random().nextInt(maxDoc); + ranges[i] = new int[] {min, TestUtil.nextInt(random(), min + 1, maxDoc + 1)}; + } + long leadCost = random().nextBoolean() ? 1 : Long.MAX_VALUE; + + List expected = new ArrayList<>(); + DocIdSetIterator reference = disjunction(leadCost, ranges(ranges)); + for (int doc = reference.nextDoc(); + doc != DocIdSetIterator.NO_MORE_DOCS; + doc = reference.nextDoc()) { + expected.add(doc); + } + + // #intoArray loads doc IDs by windows rather than one at a time, so it may return fewer doc + // IDs per call than the default implementation. It must still return the exact same doc IDs + // overall, and never return an empty array while doc IDs below upTo remain. + List actual = new ArrayList<>(); + DocIdSetIterator iterator = disjunction(leadCost, ranges(ranges)); + iterator.nextDoc(); + while (iterator.docID() != DocIdSetIterator.NO_MORE_DOCS) { + int[] docs = new int[TestUtil.nextInt(random(), 1, 4096)]; + int upTo = iterator.docID() + random().nextInt(5_000); + int size = iterator.intoArray(upTo, docs); + if (size == 0) { + assertTrue(iterator.docID() >= upTo); + continue; + } + assertTrue(size <= docs.length); + for (int i = 0; i < size; ++i) { + assertTrue(docs[i] < upTo); + actual.add(docs[i]); + } + assertTrue(iterator.docID() > docs[size - 1]); + } + assertEquals(expected, actual); + } + } + + private static DocIdSetIterator[] ranges(int[][] ranges) { + DocIdSetIterator[] iterators = new DocIdSetIterator[ranges.length]; + for (int i = 0; i < ranges.length; ++i) { + iterators[i] = DocIdSetIterator.range(ranges[i][0], ranges[i][1]); + } + return iterators; + } + + private static DisjunctionDISIApproximation disjunction( + long leadCost, DocIdSetIterator... clauses) { + List wrappers = new ArrayList<>(); + for (DocIdSetIterator clause : clauses) { + wrappers.add( + new DisiWrapper( + new ConstantScoreScorer(1f, ScoreMode.COMPLETE_NO_SCORES, clause), false)); + } + return new DisjunctionDISIApproximation(wrappers, leadCost); + } + public void testDocIDRunEndDoesNotScanOtherIterators() throws IOException { DocIdSetIterator clause1 = DocIdSetIterator.range(10_000, 30_000); DocIdSetIterator clause2 = DocIdSetIterator.range(20_000, 50_000); diff --git a/lucene/core/src/test/org/apache/lucene/search/TestDocIdSetIterator.java b/lucene/core/src/test/org/apache/lucene/search/TestDocIdSetIterator.java index 7a4508f05a4b..62c2f6340d3a 100644 --- a/lucene/core/src/test/org/apache/lucene/search/TestDocIdSetIterator.java +++ b/lucene/core/src/test/org/apache/lucene/search/TestDocIdSetIterator.java @@ -130,6 +130,32 @@ public void testIntoBitset() throws Exception { } } + public void testIntoArray() throws Exception { + DocIdSetIterator disi = DocIdSetIterator.range(5, 20); + assertEquals(5, disi.nextDoc()); + + // Stops when the array is full and leaves the iterator on the first doc that was not copied. + int[] docs = new int[4]; + assertEquals(4, disi.intoArray(20, docs)); + assertArrayEquals(new int[] {5, 6, 7, 8}, docs); + assertEquals(9, disi.docID()); + + // Stops on upTo, which is exclusive. + assertEquals(2, disi.intoArray(11, docs)); + assertEquals(9, docs[0]); + assertEquals(10, docs[1]); + assertEquals(11, disi.docID()); + + // No doc left below upTo. + assertEquals(0, disi.intoArray(11, docs)); + assertEquals(11, disi.docID()); + + docs = new int[16]; + assertEquals(9, disi.intoArray(NO_MORE_DOCS, docs)); + assertEquals(NO_MORE_DOCS, disi.docID()); + assertEquals(0, disi.intoArray(NO_MORE_DOCS, docs)); + } + public void testDocIDRunEndAll() throws IOException { DocIdSetIterator it = DocIdSetIterator.all(13); assertEquals(0, it.nextDoc()); From 06273bed1d155fd7960052243dd3aec9880b76e0 Mon Sep 17 00:00:00 2001 From: Chengxi Shi Date: Mon, 27 Jul 2026 07:58:36 -0700 Subject: [PATCH 4/6] Build ConstantScoreScorer#nextDocsAndScores on DocIdSetIterator#intoArray Iterators that load doc IDs in bulk now opt in by overriding #intoArray, which replaces the instanceof check on the underlying iterator. Also stop reporting an empty buffer while docs remain below upTo: callers read that as the iterator being exhausted, so a batch whose docs are all deleted used to drop every remaining hit of the clause. --- .../lucene/search/ConstantScoreScorer.java | 95 ++++++------------- .../search/TestConstantScoreScorer.java | 67 +++++++++++++ 2 files changed, 95 insertions(+), 67 deletions(-) diff --git a/lucene/core/src/java/org/apache/lucene/search/ConstantScoreScorer.java b/lucene/core/src/java/org/apache/lucene/search/ConstantScoreScorer.java index 5f7f998c4a58..43b5da3dce50 100644 --- a/lucene/core/src/java/org/apache/lucene/search/ConstantScoreScorer.java +++ b/lucene/core/src/java/org/apache/lucene/search/ConstantScoreScorer.java @@ -68,6 +68,17 @@ public void intoBitSet(int upTo, FixedBitSet bitSet, int offset) throws IOExcept delegate.intoBitSet(upTo, bitSet, offset); doc = delegate.docID(); } + + @Override + public int intoArray(int upTo, int[] docs) throws IOException { + if (doc != delegate.docID()) { + // See #intoBitSet. + return super.intoArray(upTo, docs); + } + int size = delegate.intoArray(upTo, docs); + doc = delegate.docID(); + return size; + } } private final float score; @@ -75,10 +86,6 @@ public void intoBitSet(int upTo, FixedBitSet bitSet, int offset) throws IOExcept private final DocIdSetIterator approximation; private final TwoPhaseIterator twoPhaseIterator; private final DocIdSetIterator disi; - // Whether the underlying iterator pays per-nextDoc() overhead that the bulk - // #nextDocsAndScores path amortizes. Iterators that are cheap to advance one doc at a time - // (single postings list, bit set) are better served by the plain doc-at-a-time loop. - private final boolean bulkDrainWorthwhile; /** * Constructor based on a {@link DocIdSetIterator} which will be used to drive iteration. Two @@ -97,7 +104,6 @@ public ConstantScoreScorer(float score, ScoreMode scoreMode, DocIdSetIterator di scoreMode == ScoreMode.TOP_SCORES ? new DocIdSetIteratorWrapper(disi) : disi; this.twoPhaseIterator = null; this.disi = this.approximation; - this.bulkDrainWorthwhile = disi instanceof DisjunctionDISIApproximation; } /** @@ -132,7 +138,6 @@ public float matchCost() { this.twoPhaseIterator = twoPhaseIterator; } this.disi = TwoPhaseIterator.asDocIdSetIterator(this.twoPhaseIterator); - this.bulkDrainWorthwhile = false; // two-phase matches must be verified one by one } @Override @@ -167,73 +172,29 @@ public float score() throws IOException { return score; } - // Doc-ID window covered by one bulk #nextDocsAndScores fill. Matches - // MaxScoreBulkScorer.INNER_WINDOW_SIZE and DenseConjunctionBulkScorer.WINDOW_SIZE, so a single - // fill covers a full inner scoring window with a bit set that stays core-cache resident. - private static final int BULK_WINDOW_SIZE = 4096; - - private FixedBitSet bulkWindowMatches; // lazily allocated + // Number of doc IDs that a single #nextDocsAndScores call loads at a time. It matches + // MaxScoreBulkScorer#INNER_WINDOW_SIZE, so that iterators which load doc IDs in bulk, such as + // disjunctions, can cover a full inner scoring window in a single call. + private static final int BATCH_SIZE = 4096; @Override public void nextDocsAndScores(int upTo, Bits liveDocs, DocAndFloatFeatureBuffer buffer) throws IOException { - if (bulkDrainWorthwhile == false) { - // Either matches must be verified one by one (two-phase), or the iterator is cheap to - // advance one doc at a time (single postings list, bit set) and the per-window fixed cost - // of the bulk path (bit set clear/flatten) would not pay for itself. Only heap-based - // composite iterators (disjunctions), which pay a priority-queue update per nextDoc(), - // benefit from the bulk drain. - int batchSize = 64; - buffer.growNoCopy(batchSize); - int size = 0; - DocIdSetIterator iterator = iterator(); - for (int doc = iterator.docID(); doc < upTo && size < batchSize; doc = iterator.nextDoc()) { - if (liveDocs == null || liveDocs.get(doc)) { - buffer.docs[size] = doc; - ++size; - } - } - Arrays.fill(buffer.features, 0, size, score); - buffer.size = size; - return; - } - - // Drain a window of matches in bulk via DocIdSetIterator#intoBitSet. Disjunctions implement - // it with one bulk load per sub-iterator, which is much cheaper than paying a priority-queue - // update per nextDoc() call. This matters for constant-score disjunction clauses under top-k - // scoring (MaxScoreBulkScorer), which have no impact-based bulk path. - buffer.size = 0; DocIdSetIterator iterator = iterator(); - int doc = iterator.docID(); - if (doc >= upTo) { - return; - } - if (bulkWindowMatches == null) { - bulkWindowMatches = new FixedBitSet(BULK_WINDOW_SIZE); - } else { - bulkWindowMatches.clear(); - } - int windowMax = (int) Math.min(upTo, (long) doc + BULK_WINDOW_SIZE); - iterator.intoBitSet(windowMax, bulkWindowMatches, doc); - int cardinality = bulkWindowMatches.cardinality(); - if (cardinality == 0) { - // No match in this window; the iterator already advanced to windowMax or beyond, the - // caller re-invokes for the next window. - return; - } - buffer.growNoCopy(cardinality); - int size = bulkWindowMatches.intoArray(0, windowMax - doc, doc, buffer.docs); - if (liveDocs != null) { - int kept = 0; - for (int i = 0; i < size; ++i) { - int d = buffer.docs[i]; - if (liveDocs.get(d)) { - buffer.docs[kept++] = d; - } + buffer.growNoCopy(BATCH_SIZE); + for (; ; ) { + buffer.size = iterator.intoArray(upTo, buffer.docs); + Arrays.fill(buffer.features, 0, buffer.size, score); + if (liveDocs == null || buffer.size == 0) { + break; + } + buffer.apply(liveDocs); + // An empty buffer indicates that there are no docs left before upTo. We may be unlucky, and + // there are docs left, but all docs from the current batch happen to be marked as deleted. + // So we need to iterate until we find a batch that has at least one non-deleted doc. + if (buffer.size != 0) { + break; } - size = kept; } - Arrays.fill(buffer.features, 0, size, score); - buffer.size = size; } } diff --git a/lucene/core/src/test/org/apache/lucene/search/TestConstantScoreScorer.java b/lucene/core/src/test/org/apache/lucene/search/TestConstantScoreScorer.java index c18cd88a2f77..f2893e523217 100644 --- a/lucene/core/src/test/org/apache/lucene/search/TestConstantScoreScorer.java +++ b/lucene/core/src/test/org/apache/lucene/search/TestConstantScoreScorer.java @@ -20,6 +20,7 @@ import static org.apache.lucene.search.DocIdSetIterator.NO_MORE_DOCS; import java.io.IOException; +import java.util.ArrayList; import java.util.List; import org.apache.lucene.analysis.Analyzer; import org.apache.lucene.document.Document; @@ -33,6 +34,7 @@ import org.apache.lucene.tests.analysis.MockAnalyzer; import org.apache.lucene.tests.index.RandomIndexWriter; import org.apache.lucene.tests.util.LuceneTestCase; +import org.apache.lucene.util.Bits; public class TestConstantScoreScorer extends LuceneTestCase { private static final String FIELD = "f"; @@ -221,6 +223,71 @@ public void close() throws IOException { } } + /** + * An empty {@link DocAndFloatFeatureBuffer} tells callers that the iterator has no doc left + * before {@code upTo}, so batches whose docs are all deleted must not be reported as such. + */ + public void testNextDocsAndScoresSkipsFullyDeletedBatches() throws IOException { + int maxDoc = 10_000; + int firstLiveDoc = 9_000; + Bits liveDocs = + new Bits() { + @Override + public boolean get(int index) { + return index >= firstLiveDoc; + } + + @Override + public int length() { + return maxDoc; + } + }; + + for (ScoreMode scoreMode : new ScoreMode[] {ScoreMode.COMPLETE, ScoreMode.TOP_SCORES}) { + for (boolean disjunction : new boolean[] {false, true}) { + DocIdSetIterator disi; + if (disjunction) { + disi = + DisjunctionDISIApproximation.of( + List.of( + new DisiWrapper( + new ConstantScoreScorer( + 1f, ScoreMode.COMPLETE_NO_SCORES, DocIdSetIterator.range(0, 5_000)), + false), + new DisiWrapper( + new ConstantScoreScorer( + 1f, + ScoreMode.COMPLETE_NO_SCORES, + DocIdSetIterator.range(5_000, maxDoc)), + false)), + maxDoc); + } else { + disi = DocIdSetIterator.all(maxDoc); + } + + ConstantScoreScorer scorer = new ConstantScoreScorer(2f, scoreMode, disi); + assertEquals(0, scorer.iterator().nextDoc()); + + DocAndFloatFeatureBuffer buffer = new DocAndFloatFeatureBuffer(); + List collected = new ArrayList<>(); + for (scorer.nextDocsAndScores(maxDoc, liveDocs, buffer); + buffer.size > 0; + scorer.nextDocsAndScores(maxDoc, liveDocs, buffer)) { + for (int i = 0; i < buffer.size; ++i) { + collected.add(buffer.docs[i]); + assertEquals(2f, buffer.features[i], 0f); + } + } + + List expected = new ArrayList<>(); + for (int doc = firstLiveDoc; doc < maxDoc; ++doc) { + expected.add(doc); + } + assertEquals(expected, collected); + } + } + } + public void testEarlyTermination() throws IOException { Analyzer analyzer = new MockAnalyzer(random()); Directory dir = newDirectory(); From 0e4b189d4d22edde762f59b843fa12b186f4bd01 Mon Sep 17 00:00:00 2001 From: Chengxi Shi Date: Mon, 27 Jul 2026 07:58:42 -0700 Subject: [PATCH 5/6] Update CHANGES entry for the intoArray-based implementation --- lucene/CHANGES.txt | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/lucene/CHANGES.txt b/lucene/CHANGES.txt index b8a3e3658140..9df5e57b67b5 100644 --- a/lucene/CHANGES.txt +++ b/lucene/CHANGES.txt @@ -338,11 +338,10 @@ Improvements Optimizations --------------------- -* GITHUB#16394: ConstantScoreScorer#nextDocsAndScores now drains disjunction-backed iterators in - bulk via DocIdSetIterator#intoBitSet instead of one nextDoc() call per match, avoiding a - priority-queue update per matching doc under MaxScoreBulkScorer's term-at-a-time scoring. - Iterators that are cheap to advance one doc at a time keep the doc-at-a-time loop. - (Chengxi Shi) +* GITHUB#16394: Add DocIdSetIterator#intoArray, which DisjunctionDISIApproximation implements by + loading a window of doc IDs through #intoBitSet rather than one nextDoc() call per match. + ConstantScoreScorer#nextDocsAndScores now builds on it, which avoids a priority-queue update per + matching doc under MaxScoreBulkScorer's term-at-a-time scoring. (Chengxi Shi) * GITHUG#16280: Single-pass writeString fast path for short strings in ByteBuffersDataOutput (neoremind) From a9304756b0b85950d02e13642d66c80e57166bf1 Mon Sep 17 00:00:00 2001 From: "chengxi.shi" Date: Mon, 27 Jul 2026 12:06:20 -0700 Subject: [PATCH 6/6] Check the nextDocsAndScores buffer contract in AssertingScorer An empty buffer is documented to mean that no doc is left before upTo, and callers rely on it to terminate. Nothing enforced it, so an implementation that filters the buffer after loading it, e.g. on liveDocs, could report an empty buffer with docs still to come and silently drop hits. Assert it, and add a randomized ConstantScoreScorer test that reaches the case. Reaching it takes a doc ID range wider than one batch plus deletions that cluster, so the uniformly random deletions that existing randomized tests apply to small indexes never get there. --- .../search/TestConstantScoreScorer.java | 91 +++++++++++++++++++ .../lucene/tests/search/AssertingScorer.java | 7 ++ 2 files changed, 98 insertions(+) diff --git a/lucene/core/src/test/org/apache/lucene/search/TestConstantScoreScorer.java b/lucene/core/src/test/org/apache/lucene/search/TestConstantScoreScorer.java index f2893e523217..ca1cc45f98b1 100644 --- a/lucene/core/src/test/org/apache/lucene/search/TestConstantScoreScorer.java +++ b/lucene/core/src/test/org/apache/lucene/search/TestConstantScoreScorer.java @@ -33,8 +33,12 @@ import org.apache.lucene.store.Directory; import org.apache.lucene.tests.analysis.MockAnalyzer; import org.apache.lucene.tests.index.RandomIndexWriter; +import org.apache.lucene.tests.search.AssertingScorer; import org.apache.lucene.tests.util.LuceneTestCase; +import org.apache.lucene.tests.util.TestUtil; +import org.apache.lucene.util.BitSetIterator; import org.apache.lucene.util.Bits; +import org.apache.lucene.util.FixedBitSet; public class TestConstantScoreScorer extends LuceneTestCase { private static final String FIELD = "f"; @@ -288,6 +292,93 @@ public int length() { } } + /** + * Randomized companion to the test above. Reaching a fully deleted batch takes a doc ID range + * that spans more than one batch together with deletions that cluster, so uniformly random + * deletions over the small indexes that most randomized tests build never get there. Scorers are + * wrapped in {@link AssertingScorer} so that the buffer contract is checked as well. + */ + public void testNextDocsAndScoresRandomClusteredDeletions() throws IOException { + int iters = atLeast(10); + for (int iter = 0; iter < iters; ++iter) { + int maxDoc = TestUtil.nextInt(random(), 12_000, 30_000); + float density = 1f / (1 << random().nextInt(3)); + + FixedBitSet matches = new FixedBitSet(maxDoc); + for (int doc = 0; doc < maxDoc; ++doc) { + if (random().nextFloat() < density) { + matches.set(doc); + } + } + if (matches.cardinality() == 0) { + continue; + } + + // A run of deleted docs wide enough to swallow a whole batch whatever its alignment, plus + // scattered deletions. + FixedBitSet liveDocs = new FixedBitSet(maxDoc); + liveDocs.set(0, maxDoc); + int runLength = TestUtil.nextInt(random(), 2 * 4096, 3 * 4096); + int runStart = TestUtil.nextInt(random(), 0, maxDoc - runLength); + liveDocs.clear(runStart, runStart + runLength); + for (int i = 0, scattered = random().nextInt(100); i < scattered; ++i) { + liveDocs.clear(random().nextInt(maxDoc)); + } + + List expected = new ArrayList<>(); + for (int doc = 0; doc < maxDoc; ++doc) { + if (matches.get(doc) && liveDocs.get(doc)) { + expected.add(doc); + } + } + + DocIdSetIterator disi; + if (random().nextBoolean()) { + int numSubs = TestUtil.nextInt(random(), 2, 4); + FixedBitSet[] subSets = new FixedBitSet[numSubs]; + for (int i = 0; i < numSubs; ++i) { + subSets[i] = new FixedBitSet(maxDoc); + } + for (int doc = 0; doc < maxDoc; ++doc) { + if (matches.get(doc)) { + subSets[random().nextInt(numSubs)].set(doc); + } + } + List subs = new ArrayList<>(); + for (FixedBitSet subSet : subSets) { + subs.add( + new DisiWrapper( + new ConstantScoreScorer( + 1f, + ScoreMode.COMPLETE_NO_SCORES, + new BitSetIterator(subSet, subSet.cardinality())), + false)); + } + disi = DisjunctionDISIApproximation.of(subs, maxDoc); + } else { + disi = new BitSetIterator(matches, matches.cardinality()); + } + + ScoreMode scoreMode = random().nextBoolean() ? ScoreMode.COMPLETE : ScoreMode.TOP_SCORES; + Scorer scorer = + AssertingScorer.wrap(new ConstantScoreScorer(2f, scoreMode, disi), true, false); + scorer.iterator().nextDoc(); + + DocAndFloatFeatureBuffer buffer = new DocAndFloatFeatureBuffer(); + List collected = new ArrayList<>(); + for (scorer.nextDocsAndScores(maxDoc, liveDocs, buffer); + buffer.size > 0; + scorer.nextDocsAndScores(maxDoc, liveDocs, buffer)) { + for (int i = 0; i < buffer.size; ++i) { + collected.add(buffer.docs[i]); + assertEquals(2f, buffer.features[i], 0f); + } + } + + assertEquals(expected, collected); + } + } + public void testEarlyTermination() throws IOException { Analyzer analyzer = new MockAnalyzer(random()); Directory dir = newDirectory(); diff --git a/lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingScorer.java b/lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingScorer.java index f0ef919761cf..263a44f24aca 100644 --- a/lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingScorer.java +++ b/lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingScorer.java @@ -298,5 +298,12 @@ public void nextDocsAndScores(int upTo, Bits liveDocs, DocAndFloatFeatureBuffer state = IteratorState.ITERATING; } } + assert buffer.size != 0 || doc >= upTo + : "An empty buffer means that no doc is left before upTo=" + + upTo + + ", but the iterator is on doc=" + + doc + + ". Implementations that filter the buffer, e.g. on liveDocs, must keep loading" + + " batches until one is non-empty."; } }