Skip to content

Bulk nextDocsAndScores for constant-score disjunction clauses#16394

Open
chengxis-mdb wants to merge 5 commits into
apache:mainfrom
chengxis-mdb:constant-score-bulk-nextdocs-upstream
Open

Bulk nextDocsAndScores for constant-score disjunction clauses#16394
chengxis-mdb wants to merge 5 commits into
apache:mainfrom
chengxis-mdb:constant-score-bulk-nextdocs-upstream

Conversation

@chengxis-mdb

@chengxis-mdb chengxis-mdb commented Jul 14, 2026

Copy link
Copy Markdown

Description

#14701 rewrote MaxScoreBulkScorer window scoring from doc-at-a-time to term-at-a-time, draining essential clauses through Scorer#nextDocsAndScores. TermScorer got a bulk implementation over postings (#14709), but ConstantScoreScorer still uses the default one-nextDoc()-per-match loop. When the constant-score clause wraps a disjunction (e.g. what a TermInSetQuery rewrites to below the boolean-rewrite threshold), every nextDoc() pays a DisiPriorityQueue update. With tied constant scores across clauses the top-k threshold stalls and cannot prune, so the entire candidate stream pays the heap maintenance — profiles of an affected workload show ~35% of CPU in DisiPriorityQueueN#updateTop/downHeap, all under the term-at-a-time paths.

This change adds DocIdSetIterator#intoArray(int upTo, int[] docs), mirroring the semantics of the existing DocIdStream#intoArray: copy doc IDs starting at the current one, stop before upTo or when the array is full, and return 0 only when no doc ID below upTo remains. The default implementation is the doc-at-a-time loop. DisjunctionDISIApproximation overrides it to load a window of doc IDs through #intoBitSet and flatten it with FixedBitSet#intoArray, which costs one bulk load and one heap update per sub-iterator instead of one heap update per matching doc — exactly the shape that regresses.

ConstantScoreScorer#nextDocsAndScores is then just a call to intoArray, so iterators opt into the bulk path by overriding the method rather than by a type check in the scorer. Two-phase iterators get the default doc-at-a-time implementation for free, since TwoPhaseIterator#asDocIdSetIterator does not override intoArray. Only DisjunctionDISIApproximation overrides it here, which keeps the change scoped to the shape we measured; other iterators could plausibly benefit too, but that deserves its own benchmarking.

The bulk window has to be bounded by the length of the target array, otherwise FixedBitSet#intoArray would silently truncate it. The batch size that ConstantScoreScorer asks for is therefore what caps the window, and it is set to 4096, matching MaxScoreBulkScorer#INNER_WINDOW_SIZE. Note this also raises the batch size of the doc-at-a-time case, which used 64 before.

Two correctness fixes come with it:

  • nextDocsAndScores no longer reports an empty buffer while docs remain below upTo. Callers such as MaxScoreBulkScorer#collectEssentialScoresIntoWindow loop on buffer.size > 0, so a batch whose docs happened to be entirely deleted used to drop every remaining hit of the clause. It now loads another batch instead, the same way TermScorer#nextDocsAndScores does, and DisjunctionDISIApproximation#intoArray does the equivalent for an empty window so the intoArray contract holds on its own. Covered by TestConstantScoreScorer#testNextDocsAndScoresSkipsFullyDeletedBatches.
  • The intoBitSet forwarding in the TOP_SCORES DocIdSetIteratorWrapper gets a guard for the delegate swap done by setMinCompetitiveScore, and the new intoArray forwarding gets the same one: without it, the forwarding would call into an unpositioned empty iterator.

Benchmark results (luceneutil, including a new constant-score disjunction task) are posted in the comments. Those numbers were measured on the earlier revision of this PR, which gated the bulk path on an instanceof check and kept a batch of 64 for the doc-at-a-time case; a rerun on the current revision will be posted.

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.
@chengxis-mdb

Copy link
Copy Markdown
Author

Benchmark results from luceneutil (wikimedium10m), run by my colleague Tianxiao Wei, including a new ConstInDisj task that issues pure-should disjunctions of constant-score term-set clauses (the shape described above):

                            TaskQPS baseline      StdDevQPS my_modified_version      StdDev                Pct diff p-value
                     ConstInDisj        8.28      (2.8%)       93.00     (89.8%) 1023.9% ( 906% - 1148%) 0.000

All other tasks are within noise (full run below). Note ConstMSM2, OrHighHigh, AndHighHigh etc. are unchanged — the gate keeps every non-disjunction-backed constant-score clause and all impact-scored clauses on their existing paths.

Full luceneutil output
                            TaskQPS baseline      StdDevQPS my_modified_version      StdDev                Pct diff p-value
           HighTermDayOfYearSort      750.80      (6.2%)      725.13      (4.2%)   -3.4% ( -13% -    7%) 0.042
                           range     6520.02      (5.3%)     6341.16      (6.7%)   -2.7% ( -14% -    9%) 0.152
                        BM25MSM2      188.93      (4.7%)      184.19      (9.4%)   -2.5% ( -15% -   12%) 0.288
             MedIntervalsOrdered     1235.81      (5.6%)     1206.70      (5.6%)   -2.4% ( -12% -    9%) 0.184
                          Fuzzy1      268.96      (1.8%)      262.73      (2.8%)   -2.3% (  -6% -    2%) 0.002
                         Prefix3     1639.66      (5.5%)     1603.03      (3.9%)   -2.2% ( -11% -    7%) 0.138
           BrowseMonthSSDVFacets      204.59      (8.6%)      200.24      (1.5%)   -2.1% ( -11% -    8%) 0.274
                     AndHighHigh     1162.49      (4.3%)     1138.39      (4.8%)   -2.1% ( -10% -    7%) 0.151
                      OrHighHigh      994.12      (3.4%)      976.73      (5.3%)   -1.7% ( -10% -    7%) 0.217
                 LowSloppyPhrase      756.00      (5.9%)      743.84      (6.1%)   -1.6% ( -12% -   11%) 0.396
                HighSloppyPhrase      443.93      (7.3%)      436.81      (6.7%)   -1.6% ( -14% -   13%) 0.466
                     LowSpanNear      861.32      (4.4%)      847.66      (4.3%)   -1.6% (  -9% -    7%) 0.253
                         Respell      186.44      (2.3%)      183.73      (2.8%)   -1.5% (  -6% -    3%) 0.071
                      HighPhrase      473.57      (4.2%)      467.95      (6.4%)   -1.2% ( -11% -    9%) 0.489
                     MedSpanNear      627.57      (3.9%)      620.20      (6.0%)   -1.2% ( -10% -    9%) 0.462
                         MedTerm     3226.22      (3.7%)     3193.18      (3.6%)   -1.0% (  -7% -    6%) 0.371
                       OrHighLow     1583.34      (2.9%)     1568.06      (3.3%)   -1.0% (  -6% -    5%) 0.321
             LowIntervalsOrdered     1423.38      (3.1%)     1411.08      (3.4%)   -0.9% (  -7% -    5%) 0.399
                 MedSloppyPhrase       97.97      (4.9%)       97.13      (4.7%)   -0.9% (  -9% -    9%) 0.571
                        Wildcard      517.48      (2.8%)      513.67      (3.8%)   -0.7% (  -7% -    6%) 0.484
                      AndHighMed     1582.62      (3.8%)     1572.13      (5.7%)   -0.7% (  -9% -    9%) 0.664
                        PKLookup      467.78      (3.5%)      465.29      (3.7%)   -0.5% (  -7% -    6%) 0.638
            BrowseDateSSDVFacets       37.58      (1.8%)       37.43      (1.2%)   -0.4% (  -3% -    2%) 0.393
            HighIntervalsOrdered      269.55      (4.7%)      268.51      (5.5%)   -0.4% ( -10% -   10%) 0.810
                       OrHighMed     1499.26      (2.5%)     1494.71      (4.4%)   -0.3% (  -6% -    6%) 0.786
                          Fuzzy2      109.82      (1.8%)      109.51      (3.0%)   -0.3% (  -4% -    4%) 0.711
                        HighTerm     2525.48      (4.3%)     2523.08      (4.5%)   -0.1% (  -8% -    9%) 0.946
                       ConstMSM2     1746.68      (4.1%)     1745.22      (5.9%)   -0.1% (  -9% -   10%) 0.959
           BrowseMonthTaxoFacets      138.20      (5.9%)      138.34      (6.8%)    0.1% ( -11% -   13%) 0.961
                          IntNRQ     1982.74      (5.1%)     1984.90      (4.5%)    0.1% (  -9% -   10%) 0.943
            BrowseDateTaxoFacets      160.59      (0.9%)      161.54      (0.6%)    0.6% (   0% -    2%) 0.017
       BrowseDayOfYearTaxoFacets      160.14      (0.8%)      161.17      (0.6%)    0.6% (   0% -    2%) 0.006
                         LowTerm     3684.93      (4.4%)     3708.81      (6.5%)    0.6% (  -9% -   12%) 0.714
       BrowseDayOfYearSSDVFacets      178.71      (3.6%)      179.93      (2.4%)    0.7% (  -5% -    6%) 0.487
     BrowseRandomLabelTaxoFacets      141.68      (2.7%)      142.71      (2.0%)    0.7% (  -3% -    5%) 0.335
                    HighSpanNear      528.43     (11.0%)      533.07     (11.5%)    0.9% ( -19% -   26%) 0.805
                       LowPhrase      939.27      (5.8%)      949.72      (3.9%)    1.1% (  -8% -   11%) 0.474
                      AndHighLow     2840.67      (3.7%)     2878.15      (4.3%)    1.3% (  -6% -    9%) 0.296
     BrowseRandomLabelSSDVFacets      127.32      (4.1%)      129.11      (4.8%)    1.4% (  -7% -   10%) 0.322
                       MedPhrase     1046.02      (4.4%)     1060.90      (4.0%)    1.4% (  -6% -   10%) 0.283
               HighTermMonthSort     1434.77      (3.4%)     1455.43      (4.7%)    1.4% (  -6% -    9%) 0.267
                     ConstInDisj        8.28      (2.8%)       93.00     (89.8%) 1023.9% ( 906% - 1148%) 0.000

We also validated end-to-end on the production-like workload where we found the regression: top-k disjunctions of dense constant-score term-set clauses recover most of the slowdown introduced in 10.3 (avg latency 316ms → ~236ms across replicated runs), while equals/point-based and impact-scored shapes are unchanged. An earlier unconditional version of this patch (bulk path for every constant-score clause) measurably regressed single-postings and bit-set-backed clauses, which is what motivated the DisjunctionDISIApproximation gate.

@romseygeek romseygeek 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.

Thanks for opening @chengxis-mdb! I left some comments.

scoreMode == ScoreMode.TOP_SCORES ? new DocIdSetIteratorWrapper(disi) : disi;
this.twoPhaseIterator = null;
this.disi = this.approximation;
this.bulkDrainWorthwhile = disi instanceof DisjunctionDISIApproximation;

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.

I don't think we want to be doing instanceof checks here. Can we instead look at implementing an intoArray() method on DocIdSetIterator, with a default implementation?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, the instanceof was the weak part of this. Done in 38dea2f and 06273be.

DocIdSetIterator#intoArray(int upTo, int[] docs) now mirrors DocIdStream#intoArray: the default implementation is the doc-at-a-time loop, DisjunctionDISIApproximation overrides it to load a window through #intoBitSet, and ConstantScoreScorer#nextDocsAndScores just calls it — no instanceof, no gate, and the two-phase case gets the default implementation for free since TwoPhaseIterator#asDocIdSetIterator doesn't override it. ConstantScoreScorer.DocIdSetIteratorWrapper forwards intoArray to its delegate (with the same guard as intoBitSet) so TOP_SCORES clauses still reach the bulk implementation.

Two things worth flagging:

  • The window has to be bounded by docs.length, otherwise FixedBitSet#intoArray silently truncates it and we lose docs. So the batch size the scorer asks for is what caps the window, and I set it to 4096, matching MaxScoreBulkScorer#INNER_WINDOW_SIZE. That is above the "8 and a couple hundreds" guidance on Scorer#nextDocsAndScores, and it now also applies to the doc-at-a-time case, which used a batch of 64 before. I'm happy to lower it if you'd rather keep the buffers small, but it isn't neutral for the workload that motivated this: the saving is roughly window_size × density / num_sub_iterators heap operations, and at the ~4% per-clause density we see, 512 is close to break-even while 4096 is a ~16x reduction. I'll re-run luceneutil on this shape and post fresh numbers either way.
  • Only DisjunctionDISIApproximation overrides intoArray for now, which keeps this change to the shape we actually measured. BitSetIterator and the postings iterators could plausibly override it too, but that deserves its own benchmarking.

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

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.

I don't think this behaviour is correct? If we return an empty DocAndFloatFeatureBuffer then the caller will assume that the iterator is exhausted.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, and the bug is worse than the case I wrote that comment for. Thanks for catching it.

The cardinality == 0 branch itself turned out to be unreachable: nextDocsAndScores starts on the current doc, the current doc is always inside the window, so intoBitSet always sets at least one bit. But the liveDocs filter right below it could empty the buffer while docs remained below upTo, and callers such as MaxScoreBulkScorer#collectEssentialScoresIntoWindow and #scoreInnerWindowSingleEssentialClause loop on buffer.size > 0. So a clause whose current batch happened to be entirely deleted dropped all of its remaining hits.

Fixed in 06273be: nextDocsAndScores now loops until a batch has at least one live doc or the iterator has nothing left below upTo, the same shape as TermScorer#nextDocsAndScores. DisjunctionDISIApproximation#intoArray does the equivalent for an empty window, so the intoArray contract ("never return 0 while doc IDs below upTo remain") holds independently of the caller.

Regression test in TestConstantScoreScorer#testNextDocsAndScoresSkipsFullyDeletedBatches: 10k docs of which only the last 1000 are live, drained through the same loop shape the bulk scorers use, for both COMPLETE and TOP_SCORES and both a plain and a disjunction-backed iterator. Against the previous commit the disjunction case returns [] on the very first call — all 1000 matching live docs lost — and it now returns all of them.

…tions

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.
…rray

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.
@romseygeek

Copy link
Copy Markdown
Contributor

Thanks @chengxis-mdb, this looks much better.

I'll re-run luceneutil on this shape and post fresh numbers either way.

That would be great.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants