Skip to content

Bulk nextDocsAndScores for constant-score disjunction clauses - #16394

Open
chengxis-mdb wants to merge 6 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 6 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.

The first of those two is a bug the buffer contract should have ruled out on its own: Scorer#nextDocsAndScores documents that an empty buffer means no doc is left before upTo, and every caller relies on it to terminate, but nothing checked it. AssertingScorer now asserts it, next to the existing intoBitSet post-condition.

To be clear about what that assertion buys, it does not surface the bug by itself: running all of lucene:core against the unfixed code (8536 tests, -Ptests.asserts=true) never trips it. Reaching a violation needs a doc ID range wider than one batch together with deletions clustered enough to wipe out a whole batch, and the uniformly random deletions that randomized tests apply to small indexes do not produce that. TestConstantScoreScorer#testNextDocsAndScoresRandomClusteredDeletions builds that shape and drives the scorer through AssertingScorer; it fails on the unfixed code both with assertions on (the new assert) and off (assertEquals). What the assertion buys is that any future violation fails at the violation instead of as a missing-hits mismatch somewhere downstream.

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.

@txwei

txwei commented Jul 27, 2026

Copy link
Copy Markdown
Contributor
                            TaskQPS baseline      StdDevQPS my_modified_version      StdDev                Pct diff p-value
                        BM25MSM2      181.80      (6.0%)      173.72      (9.1%)   -4.4% ( -18% -   11%) 0.069
                         MedTerm     3034.78      (4.6%)     2916.02      (7.5%)   -3.9% ( -15% -    8%) 0.046
                     AndHighHigh     1250.96      (4.0%)     1210.15      (3.8%)   -3.3% ( -10% -    4%) 0.008
           BrowseMonthSSDVFacets      203.67      (8.3%)      198.14      (1.7%)   -2.7% ( -11% -    7%) 0.150
            HighIntervalsOrdered       85.67      (9.3%)       83.58      (9.1%)   -2.4% ( -19% -   17%) 0.403
           HighTermDayOfYearSort      820.84      (4.3%)      801.21      (3.9%)   -2.4% ( -10% -    6%) 0.067
                    HighSpanNear      501.88      (4.2%)      490.11      (6.9%)   -2.3% ( -12% -    9%) 0.196
              DisjMedAndHighWide      101.72      (1.8%)       99.73      (3.8%)   -2.0% (  -7% -    3%) 0.037
                      AndHighLow     3260.26      (5.1%)     3198.29      (7.0%)   -1.9% ( -13% -   10%) 0.327
     BrowseRandomLabelSSDVFacets      131.24      (5.6%)      128.80      (3.3%)   -1.9% ( -10% -    7%) 0.197
               HighTermMonthSort     1582.07      (3.5%)     1554.18      (3.6%)   -1.8% (  -8% -    5%) 0.117
                      OrHighHigh     1241.52      (3.6%)     1220.29      (4.5%)   -1.7% (  -9% -    6%) 0.181
                           range     6971.48      (7.5%)     6869.80      (8.5%)   -1.5% ( -16% -   15%) 0.565
                       LowPhrase     1444.04      (3.1%)     1425.24      (5.6%)   -1.3% (  -9% -    7%) 0.366
                 DisjLowAndHigh2      645.44      (2.9%)      637.11      (4.0%)   -1.3% (  -7% -    5%) 0.242
       BrowseDayOfYearSSDVFacets      179.37      (3.7%)      177.09      (3.3%)   -1.3% (  -8% -    5%) 0.255
                       OrHighMed     1720.21      (4.5%)     1698.72      (5.7%)   -1.2% ( -10% -    9%) 0.441
                         Prefix3     1061.67      (7.9%)     1048.83      (7.0%)   -1.2% ( -14% -   14%) 0.609
                  DisjLowAndHigh      371.91      (4.7%)      367.89      (4.9%)   -1.1% ( -10% -    8%) 0.477
                       MedPhrase      734.99      (3.5%)      727.23      (5.0%)   -1.1% (  -9% -    7%) 0.437
                      AndHighMed     1894.33      (4.5%)     1874.44      (4.3%)   -1.0% (  -9% -    8%) 0.452
                        PKLookup      471.08      (3.0%)      467.08      (3.8%)   -0.8% (  -7% -    6%) 0.434
                  DisjMedAndHigh      131.22      (3.0%)      130.14      (3.8%)   -0.8% (  -7% -    6%) 0.446
                     MedSpanNear      145.85      (4.3%)      144.65      (6.1%)   -0.8% ( -10% -   10%) 0.624
                 MedSloppyPhrase     1044.13      (6.5%)     1036.10      (6.1%)   -0.8% ( -12% -   12%) 0.700
                         LowTerm     3821.40      (3.9%)     3796.20      (5.3%)   -0.7% (  -9% -    8%) 0.655
                   DisjLowAndMed      529.80      (4.4%)      526.55      (3.3%)   -0.6% (  -7% -    7%) 0.619
                 LowSloppyPhrase      520.69      (5.7%)      517.63      (7.0%)   -0.6% ( -12% -   12%) 0.772
             MedIntervalsOrdered      187.55      (1.7%)      186.48      (2.8%)   -0.6% (  -4% -    3%) 0.430
                          IntNRQ     2250.41      (3.8%)     2241.62      (5.3%)   -0.4% (  -9% -    9%) 0.789
                         Respell      203.48      (2.0%)      202.76      (1.9%)   -0.4% (  -4% -    3%) 0.566
                      HighPhrase      520.41      (4.2%)      519.18      (5.2%)   -0.2% (  -9% -    9%) 0.873
                          Fuzzy2       73.65      (1.7%)       73.49      (1.8%)   -0.2% (  -3% -    3%) 0.690
       BrowseDayOfYearTaxoFacets      160.39      (0.7%)      160.12      (1.3%)   -0.2% (  -2% -    1%) 0.635
            BrowseDateSSDVFacets       37.22      (1.3%)       37.18      (1.3%)   -0.1% (  -2% -    2%) 0.782
                       OrHighLow     1726.72      (4.2%)     1725.16      (5.0%)   -0.1% (  -8% -    9%) 0.951
                       ConstMSM2     1745.13      (3.6%)     1744.02      (6.6%)   -0.1% (  -9% -   10%) 0.970
                     LowSpanNear      755.12      (2.1%)      755.04      (2.3%)   -0.0% (  -4% -    4%) 0.989
     BrowseRandomLabelTaxoFacets      143.03      (2.3%)      143.03      (2.5%)    0.0% (  -4% -    4%) 0.995
            BrowseDateTaxoFacets      160.04      (0.6%)      160.14      (1.1%)    0.1% (  -1% -    1%) 0.824
             LowIntervalsOrdered     2320.69      (5.2%)     2334.16      (4.1%)    0.6% (  -8% -   10%) 0.695
                          Fuzzy1      259.81      (2.0%)      261.36      (2.9%)    0.6% (  -4% -    5%) 0.454
                        Wildcard      261.75      (2.5%)      263.48      (3.7%)    0.7% (  -5% -    6%) 0.502
                        HighTerm     2495.22      (4.7%)     2529.06      (5.2%)    1.4% (  -8% -   11%) 0.390
           BrowseMonthTaxoFacets      134.17      (7.1%)      136.09      (6.5%)    1.4% ( -11% -   16%) 0.505
                HighSloppyPhrase      238.28      (8.2%)      246.18      (4.0%)    3.3% (  -8% -   16%) 0.102
                     ConstInDisj        9.26      (2.1%)      117.64     (82.8%) 1170.1% (1063% - 1281%) 0.000

The benchmark results from this luceneutil branch look good from the recent changes

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.
chengxis-mdb added a commit to mongodb-forks/lucene-mongot that referenced this pull request Jul 27, 2026
…ingScorer

An empty buffer is documented to mean that no doc is left before upTo, and
callers rely on it to terminate. Nothing enforced it, which is how the bug fixed
in the previous commit went unnoticed.

Assert it, and add a randomized test that reaches the case. Reaching it takes a
doc ID range wider than one window plus deletions that cluster, so the uniformly
random deletions that existing randomized tests apply to small indexes never get
there: running the whole lucene:core suite against the unfixed code does not trip
the assertion, only the targeted tests do.

Backport of the same change on apache#16394.
@chengxis-mdb
chengxis-mdb requested a review from romseygeek July 27, 2026 19:16
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.

3 participants