You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@lucene.apache.org by ro...@apache.org on 2026/07/07 12:39:08 UTC

(lucene) branch main updated: Order two-phase iterators in DenseConjunctionBulkScorer by matchCost (#16370)

This is an automated email from the ASF dual-hosted git repository.

romseygeek pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/lucene.git


The following commit(s) were added to refs/heads/main by this push:
     new c4e4e9b1cf8 Order two-phase iterators in DenseConjunctionBulkScorer by matchCost (#16370)
c4e4e9b1cf8 is described below

commit c4e4e9b1cf80ebe4c258aba2b0cce16a1bd2d0e5
Author: Alan Woodward <ro...@apache.org>
AuthorDate: Tue Jul 7 13:39:01 2026 +0100

    Order two-phase iterators in DenseConjunctionBulkScorer by matchCost (#16370)
    
    Currently two-phase iterators are ordered by their approximation
    cost. Many two-phase implementations use maxDoc as their approximation,
    however, meaning that we don't necessarily get a good ordering
    between them and more expensive matches can be called unnecessarily.
    
    This adds an additional comparison layer for two-phase iterators
    that orders by matchCost.
---
 lucene/CHANGES.txt                                 |  3 +
 .../lucene/search/DenseConjunctionBulkScorer.java  |  6 +-
 .../search/TestDenseConjunctionBulkScorer.java     | 85 ++++++++++++++++++++++
 3 files changed, 93 insertions(+), 1 deletion(-)

diff --git a/lucene/CHANGES.txt b/lucene/CHANGES.txt
index 12b928dae11..125fd7cf6d8 100644
--- a/lucene/CHANGES.txt
+++ b/lucene/CHANGES.txt
@@ -337,6 +337,9 @@ Optimizations
 * GITHUB#16355: Add ASCII fast-path to CharacterUtils.toLowerCase, avoiding per-char codepoint
   conversion for ASCII-only tokens. 2.16x throughput on English text. (Costin Leau)
 
+* GITHUB#16370: Sort two-phase iterators by matchCost in DenseConjunctionBulkScorer.
+  (Alan Woodward)
+
 Bug Fixes
 ---------------------
 * GITHUB#16350: Disable bulk-scoring in monitor queries. (Alan Woodward)
diff --git a/lucene/core/src/java/org/apache/lucene/search/DenseConjunctionBulkScorer.java b/lucene/core/src/java/org/apache/lucene/search/DenseConjunctionBulkScorer.java
index 41f5e71f2d9..9f59eb2c2bb 100644
--- a/lucene/core/src/java/org/apache/lucene/search/DenseConjunctionBulkScorer.java
+++ b/lucene/core/src/java/org/apache/lucene/search/DenseConjunctionBulkScorer.java
@@ -112,9 +112,13 @@ final class DenseConjunctionBulkScorer extends BulkScorer {
     }
     // Plain approximations before two-phase ones, so matches() only runs on docs that already
     // satisfy every approximation; within each group, cheapest approximation first (lead skipping).
+    // Two-phase clauses that tie on approximation cost are further ordered by matchCost, so a cheap
+    // confirmation (e.g. a doc values range) runs before an expensive one (e.g. a script) even when
+    // both report the same approximation cost.
     this.iterators.sort(
         Comparator.<DisiWrapper>comparingInt(w -> w.twoPhase() == null ? 0 : 1)
-            .thenComparingLong(w -> w.approximation().cost()));
+            .thenComparingLong(w -> w.approximation().cost())
+            .thenComparingDouble(w -> w.twoPhase() == null ? 0 : w.twoPhase().matchCost()));
     this.scorable = new SimpleScorable();
     scorable.score = constantScore;
   }
diff --git a/lucene/core/src/test/org/apache/lucene/search/TestDenseConjunctionBulkScorer.java b/lucene/core/src/test/org/apache/lucene/search/TestDenseConjunctionBulkScorer.java
index c05cb11a543..c23e94d7ab0 100644
--- a/lucene/core/src/test/org/apache/lucene/search/TestDenseConjunctionBulkScorer.java
+++ b/lucene/core/src/test/org/apache/lucene/search/TestDenseConjunctionBulkScorer.java
@@ -1067,6 +1067,91 @@ public class TestDenseConjunctionBulkScorer extends LuceneTestCase {
     assertEquals(30_000, collector.count);
   }
 
+  /**
+   * When two two-phase clauses tie on approximation cost, the scorer must still confirm the clause
+   * with the cheaper {@link TwoPhaseIterator#matchCost()} first, so that it thins out candidates
+   * before the more expensive confirmation runs. The clauses are passed to the constructor with the
+   * expensive one first, so any observed ordering comes purely from the {@code matchCost}
+   * tie-break.
+   */
+  public void testTwoPhaseIteratorsOrderedByMatchCost() throws IOException {
+    int maxDoc = 100_000;
+
+    // A plain (non-two-phase) leading clause that matches every other doc.
+    FixedBitSet leadMatches = new FixedBitSet(maxDoc);
+    for (int i = 0; i < maxDoc; i += 2) {
+      leadMatches.set(i);
+    }
+    DocIdSetIterator lead = new BitSetIterator(leadMatches, leadMatches.approximateCardinality());
+
+    int[] cheapCalls = {0};
+    int[] expensiveCalls = {0};
+
+    // Cheap to confirm, and selective: only matches 1 in 8 candidates.
+    TwoPhaseIterator cheap =
+        new TwoPhaseIterator(DocIdSetIterator.all(maxDoc)) {
+          @Override
+          public boolean matches() throws IOException {
+            cheapCalls[0]++;
+            return approximation().docID() % 8 == 0;
+          }
+
+          @Override
+          public float matchCost() {
+            return 1f;
+          }
+        };
+    // Expensive to confirm, and matches everything it's asked about.
+    TwoPhaseIterator expensive =
+        new TwoPhaseIterator(DocIdSetIterator.all(maxDoc)) {
+          @Override
+          public boolean matches() throws IOException {
+            expensiveCalls[0]++;
+            return true;
+          }
+
+          @Override
+          public float matchCost() {
+            return 1000f;
+          }
+        };
+
+    BulkScorer scorer =
+        new DenseConjunctionBulkScorer(
+            Collections.singletonList(lead), Arrays.asList(expensive, cheap), maxDoc, 0f);
+    FixedBitSet result = new FixedBitSet(maxDoc);
+    scorer.score(
+        new LeafCollector() {
+          @Override
+          public void setScorer(Scorable scorer) throws IOException {}
+
+          @Override
+          public void collect(int doc) throws IOException {
+            result.set(doc);
+          }
+        },
+        null,
+        0,
+        DocIdSetIterator.NO_MORE_DOCS);
+
+    FixedBitSet expected = new FixedBitSet(maxDoc);
+    for (int i = 0; i < maxDoc; i += 8) {
+      expected.set(i);
+    }
+    assertEquals(expected, result);
+
+    // The cheap clause runs first and thins out the candidate set, so the expensive clause -- even
+    // though it matches every candidate it's asked about -- gets confirmed less often. Without the
+    // matchCost tie-break, both clauses tie on approximation cost and the expensive one would be
+    // confirmed just as often as the cheap one.
+    assertTrue(
+        "expensive matchCost clause should be confirmed less often than the cheap one: cheap="
+            + cheapCalls[0]
+            + " expensive="
+            + expensiveCalls[0],
+        expensiveCalls[0] < cheapCalls[0]);
+  }
+
   public void testMixedTwoPhaseRangeIntersection() throws IOException {
     int maxDoc = 100_000;
     DocIdSetIterator clause1 = DocIdSetIterator.range(10_000, 60_000);