Skip to content

Commit bc72389

Browse files
committed
add bulk intoBitSet for SortedDocValues ordinal ranges
1 parent 8fe701c commit bc72389

7 files changed

Lines changed: 285 additions & 9 deletions

File tree

lucene/CHANGES.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,8 @@ Optimizations
325325

326326
* GITHUB#16352: Better cost() estimation for SkipBlockRangeIterator. (Alan Woodward)
327327

328+
* GITHUB#16180: Add bulk intoBitSet for SortedDocValues ordinal ranges. (Prithvi S)
329+
328330
Bug Fixes
329331
---------------------
330332

lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesProducer.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1478,6 +1478,15 @@ public void intoBitSet(int upTo, FixedBitSet bitSet, int offset) throws IOExcept
14781478
advance(upTo);
14791479
}
14801480
}
1481+
1482+
@Override
1483+
public void ordinalRangeIntoBitSet(
1484+
int fromDoc, int toDoc, long minOrd, long maxOrd, FixedBitSet bitSet, int offset)
1485+
throws IOException {
1486+
// Dense packed ordinals: bulk evaluate via the same SIMD path as NumericDocValues
1487+
Lucene90DocValuesProducer.rangeIntoBitSet(
1488+
values, fromDoc, toDoc, minOrd, maxOrd, bitSet, offset);
1489+
}
14811490
};
14821491
} else if (ordsEntry.docsWithFieldOffset >= 0) { // sparse but non-empty
14831492
final IndexedDISI disi =
@@ -1576,6 +1585,15 @@ public int docIDRunEnd() throws IOException {
15761585
public void intoBitSet(int upTo, FixedBitSet bitSet, int offset) throws IOException {
15771586
ords.intoBitSet(upTo, bitSet, offset);
15781587
}
1588+
1589+
@Override
1590+
public void ordinalRangeIntoBitSet(
1591+
int fromDoc, int toDoc, long minOrd, long maxOrd, FixedBitSet bitSet, int offset)
1592+
throws IOException {
1593+
// Delegate to the underlying NumericDocValues which already has a (possibly SIMD)
1594+
// rangeIntoBitSet override.
1595+
ords.rangeIntoBitSet(fromDoc, toDoc, minOrd, maxOrd, bitSet, offset);
1596+
}
15791597
};
15801598
}
15811599

lucene/core/src/java/org/apache/lucene/index/SortedDocValues.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import java.io.IOException;
2020
import org.apache.lucene.util.BytesRef;
21+
import org.apache.lucene.util.FixedBitSet;
2122
import org.apache.lucene.util.automaton.CompiledAutomaton;
2223

2324
/**
@@ -95,6 +96,34 @@ public TermsEnum termsEnum() throws IOException {
9596
return new SortedDocValuesTermsEnum(this);
9697
}
9798

99+
/**
100+
* Fills {@code bitSet} with the doc IDs in {@code [fromDoc, toDoc)} whose ordinals are in {@code
101+
* [minOrd, maxOrd]}. This is a bulk operation that avoids per-doc virtual dispatch overhead.
102+
*
103+
* <p>The default implementation falls back to per-doc evaluation via {@link #advanceExact} and
104+
* {@link #ordValue}. Subclasses with random-access storage (e.g., dense fixed-bitsPerValue
105+
* fields) can override this for significantly better performance.
106+
*
107+
* @param fromDoc first doc ID to evaluate (inclusive)
108+
* @param toDoc last doc ID to evaluate (exclusive)
109+
* @param minOrd lower bound of the ordinal range (inclusive)
110+
* @param maxOrd upper bound of the ordinal range (inclusive)
111+
* @param bitSet the bitset to fill
112+
* @param offset subtracted from each doc ID before setting the bit
113+
*/
114+
public void ordinalRangeIntoBitSet(
115+
int fromDoc, int toDoc, long minOrd, long maxOrd, FixedBitSet bitSet, int offset)
116+
throws IOException {
117+
for (int d = fromDoc; d < toDoc; d++) {
118+
if (advanceExact(d)) {
119+
int ord = ordValue();
120+
if (ord >= minOrd && ord <= maxOrd) {
121+
bitSet.set(d - offset);
122+
}
123+
}
124+
}
125+
}
126+
98127
/**
99128
* Returns a {@link TermsEnum} over the values, filtered by a {@link CompiledAutomaton} The enum
100129
* supports {@link TermsEnum#ord()}.

lucene/core/src/java/org/apache/lucene/search/DocValuesRangeIterator.java

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,8 @@ public static DocValuesRangeIterator forOrdinalRange(
9999
};
100100
return skipper == null
101101
? new DocValuesValueRangeIterator(values, check, 2)
102-
: new BulkOrdinalRangeIterator(
103-
values, new SkipBlockRangeIterator(skipper, min, max), check, 2);
102+
: new BulkSortedRangeIterator(
103+
values, new SkipBlockRangeIterator(skipper, min, max), check, 2, min, max);
104104
}
105105

106106
/**
@@ -429,9 +429,43 @@ void intoMaybeBlock(int blockStart, int blockEnd, FixedBitSet bitSet, int offset
429429
}
430430

431431
/**
432-
* Bulk range iterator over ordinal (sorted / sorted-set) doc values. There is no columnar
433-
* range-decode like the numeric path, so MAYBE blocks confirm the ordinal predicate one doc at a
434-
* time, visiting only docs that have a value.
432+
* Bulk range iterator over single-valued sorted (ordinal) doc values. Delegates MAYBE blocks to
433+
* {@link SortedDocValues#ordinalRangeIntoBitSet} so that dense packed implementations can use
434+
* direct (SIMD) range evaluation over the ordinal array.
435+
*/
436+
private static final class BulkSortedRangeIterator extends BulkBlockRangeIterator {
437+
438+
private final SortedDocValues sortedValues;
439+
private final long minOrd;
440+
private final long maxOrd;
441+
442+
private BulkSortedRangeIterator(
443+
SortedDocValues values,
444+
SkipBlockRangeIterator blockIterator,
445+
IOBooleanSupplier predicate,
446+
float matchCost,
447+
long minOrd,
448+
long maxOrd) {
449+
super(values, blockIterator, predicate, matchCost);
450+
this.sortedValues = values;
451+
this.minOrd = minOrd;
452+
this.maxOrd = maxOrd;
453+
}
454+
455+
@Override
456+
void intoMaybeBlock(int blockStart, int blockEnd, FixedBitSet bitSet, int offset)
457+
throws IOException {
458+
// sortedValues is the same instance as disi, so a preceding matches() call may have
459+
// moved it beyond blockStart. Adjust the starting point.
460+
int from = Math.max(blockStart, sortedValues.docID());
461+
sortedValues.ordinalRangeIntoBitSet(from, blockEnd, minOrd, maxOrd, bitSet, offset);
462+
}
463+
}
464+
465+
/**
466+
* Bulk range iterator over ordinal (sorted-set) doc values. There is no columnar range-decode
467+
* like the numeric path, and for multi-valued we must check the predicate (which walks the
468+
* per-doc ords), so MAYBE blocks confirm by visiting only docs that have a value.
435469
*/
436470
private static final class BulkOrdinalRangeIterator extends BulkBlockRangeIterator {
437471

lucene/core/src/test/org/apache/lucene/search/TestDocValuesOrdinalRangeIterator.java

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,12 @@ public int getValueCount() {
134134

135135
@Override
136136
public boolean advanceExact(int target) {
137-
throw new UnsupportedOperationException();
137+
if (docHasValue(target)) {
138+
doc = target;
139+
return true;
140+
}
141+
doc = target;
142+
return false;
138143
}
139144

140145
@Override
@@ -349,7 +354,12 @@ public long getValueCount() {
349354

350355
@Override
351356
public boolean advanceExact(int target) {
352-
throw new UnsupportedOperationException();
357+
if (docHasValue(target)) {
358+
doc = target;
359+
return true;
360+
}
361+
doc = target;
362+
return false;
353363
}
354364

355365
@Override

lucene/core/src/test/org/apache/lucene/search/TestDocValuesOrdinalSetIterator.java

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,12 @@ public int getValueCount() {
209209

210210
@Override
211211
public boolean advanceExact(int target) {
212-
throw new UnsupportedOperationException();
212+
if (docHasValue(target)) {
213+
doc = target;
214+
return true;
215+
}
216+
doc = target;
217+
return false;
213218
}
214219

215220
@Override
@@ -500,7 +505,12 @@ public long getValueCount() {
500505

501506
@Override
502507
public boolean advanceExact(int target) {
503-
throw new UnsupportedOperationException();
508+
if (docHasValue(target)) {
509+
doc = target;
510+
return true;
511+
}
512+
doc = target;
513+
return false;
504514
}
505515

506516
@Override

lucene/core/src/test/org/apache/lucene/search/TestSkipBlockRangeIteratorIntoBitSet.java

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,13 @@
1919
import java.io.IOException;
2020
import java.util.ArrayList;
2121
import java.util.List;
22+
import java.util.Locale;
2223
import java.util.Random;
2324
import java.util.function.LongUnaryOperator;
2425
import org.apache.lucene.codecs.lucene104.Lucene104Codec;
2526
import org.apache.lucene.document.Document;
2627
import org.apache.lucene.document.NumericDocValuesField;
28+
import org.apache.lucene.document.SortedDocValuesField;
2729
import org.apache.lucene.document.SortedNumericDocValuesField;
2830
import org.apache.lucene.index.DirectoryReader;
2931
import org.apache.lucene.index.DocValues;
@@ -32,9 +34,11 @@
3234
import org.apache.lucene.index.IndexWriterConfig;
3335
import org.apache.lucene.index.LeafReaderContext;
3436
import org.apache.lucene.index.NumericDocValues;
37+
import org.apache.lucene.index.SortedDocValues;
3538
import org.apache.lucene.index.SortedNumericDocValues;
3639
import org.apache.lucene.search.BooleanClause.Occur;
3740
import org.apache.lucene.store.Directory;
41+
import org.apache.lucene.util.BytesRef;
3842
import org.apache.lucene.util.FixedBitSet;
3943

4044
/**
@@ -579,6 +583,175 @@ public void testAllBlockTypesWithFakeSkipper() throws Exception {
579583
}
580584
}
581585

586+
/**
587+
* Tests the intoBitSet bulk path for SortedDocValues ordinal ranges. Dense field with values
588+
* repeating every 100 docs, queried over an ordinal range that exercises YES, MAYBE, and NO
589+
* blocks.
590+
*/
591+
public void testOrdinalIntoBitSetMatchesLinearScan() throws Exception {
592+
try (Directory ordDir = newDirectory()) {
593+
IndexWriterConfig iwc = new IndexWriterConfig().setCodec(new Lucene104Codec());
594+
try (IndexWriter w = new IndexWriter(ordDir, iwc)) {
595+
for (int i = 0; i < DOC_COUNT; i++) {
596+
Document doc = new Document();
597+
// ordinals = i % 100, deterministic, formatted as zero-padded strings
598+
String val = String.format(Locale.ROOT, "%03d", i % 100);
599+
doc.add(SortedDocValuesField.indexedField("ord", new BytesRef(val)));
600+
w.addDocument(doc);
601+
}
602+
w.forceMerge(1);
603+
}
604+
605+
try (DirectoryReader ordReader = DirectoryReader.open(ordDir)) {
606+
LeafReaderContext ctx = ordReader.leaves().get(0);
607+
int maxDoc = ctx.reader().maxDoc();
608+
int windowSize = DenseConjunctionBulkScorer.WINDOW_SIZE;
609+
610+
// Linear scan reference
611+
FixedBitSet expected = new FixedBitSet(windowSize);
612+
SortedDocValues refValues = ctx.reader().getSortedDocValues("ord");
613+
for (int d = 0; d < Math.min(maxDoc, windowSize); d++) {
614+
if (refValues.advanceExact(d)) {
615+
int ord = refValues.ordValue();
616+
if (ord >= 20 && ord <= 40) {
617+
expected.set(d);
618+
}
619+
}
620+
}
621+
622+
DocValuesSkipper skipper = ctx.reader().getDocValuesSkipper("ord");
623+
SortedDocValues dv = ctx.reader().getSortedDocValues("ord");
624+
assertNotNull("Field must have a skip index", skipper);
625+
626+
DocValuesRangeIterator iter = DocValuesRangeIterator.forOrdinalRange(dv, skipper, 20, 40);
627+
iter.approximation().nextDoc();
628+
629+
FixedBitSet actual = new FixedBitSet(windowSize);
630+
iter.intoBitSet(Math.min(maxDoc, windowSize), actual, 0);
631+
632+
assertEquals(
633+
"Ordinal intoBitSet must match linear scan",
634+
expected.cardinality(),
635+
actual.cardinality());
636+
FixedBitSet diff = expected.clone();
637+
diff.xor(actual);
638+
assertEquals("No bits should differ for ordinal range", 0, diff.cardinality());
639+
}
640+
}
641+
}
642+
643+
/**
644+
* Tests that the two-phase iterator is wired for SortedDocValues ordinal ranges with a skip
645+
* index.
646+
*/
647+
public void testOrdinalTwoPhaseIteratorIsWired() throws Exception {
648+
try (Directory ordDir = newDirectory()) {
649+
IndexWriterConfig iwc = new IndexWriterConfig().setCodec(new Lucene104Codec());
650+
try (IndexWriter w = new IndexWriter(ordDir, iwc)) {
651+
for (int i = 0; i < 10000; i++) {
652+
Document doc = new Document();
653+
String val = String.format(Locale.ROOT, "%03d", i % 100);
654+
doc.add(SortedDocValuesField.indexedField("ord", new BytesRef(val)));
655+
w.addDocument(doc);
656+
}
657+
w.forceMerge(1);
658+
}
659+
660+
try (DirectoryReader ordReader = DirectoryReader.open(ordDir)) {
661+
LeafReaderContext ctx = ordReader.leaves().get(0);
662+
Query q =
663+
SortedDocValuesField.newSlowRangeQuery(
664+
"ord", new BytesRef("020"), new BytesRef("040"), true, true);
665+
IndexSearcher searcher = new IndexSearcher(ordReader);
666+
Weight weight = q.createWeight(searcher, ScoreMode.COMPLETE_NO_SCORES, 1f);
667+
ScorerSupplier ss = weight.scorerSupplier(ctx);
668+
assertNotNull("ScorerSupplier must not be null", ss);
669+
assertTrue(
670+
"ScorerSupplier must be a ConstantScoreScorerSupplier",
671+
ss instanceof ConstantScoreScorerSupplier);
672+
DocIdSetIterator iter = ((ConstantScoreScorerSupplier) ss).iterator(Long.MAX_VALUE);
673+
TwoPhaseIterator twoPhase = TwoPhaseIterator.unwrap(iter);
674+
assertTrue(
675+
"Ordinal range query on single-valued field with skip index must use a"
676+
+ " DocValuesRangeIterator, but got: "
677+
+ (twoPhase == null ? iter.getClass() : twoPhase.getClass()).getSimpleName(),
678+
twoPhase instanceof DocValuesRangeIterator);
679+
}
680+
}
681+
}
682+
683+
/**
684+
* Tests that ordinalRangeIntoBitSet (bulk/fast path) produces the exact same results as per-doc
685+
* evaluation (slow path) across random data with various densities, range selectivities, and edge
686+
* cases (all-match, no-match, single-doc blocks, boundary docs).
687+
*/
688+
public void testOrdinalRangeIntoBitSetMatchesPerDocEvaluation() throws Exception {
689+
Random rng = random();
690+
for (int iter = 0; iter < 10; iter++) {
691+
int numDocs = rng.nextInt(4096, 4096 * 5);
692+
int numUnique = rng.nextInt(10, 500);
693+
// Random range selectivity in terms of ordinals
694+
int minOrd = rng.nextInt(0, numUnique / 2);
695+
int maxOrd = minOrd + rng.nextInt(1, numUnique / 2);
696+
697+
try (Directory dir = newDirectory()) {
698+
IndexWriterConfig iwc = new IndexWriterConfig().setCodec(new Lucene104Codec());
699+
try (IndexWriter w = new IndexWriter(dir, iwc)) {
700+
for (int i = 0; i < numDocs; i++) {
701+
Document doc = new Document();
702+
// Use string values so ordinal ordering is deterministic and independent of
703+
// byte-value ordering.
704+
String val = String.format(Locale.ROOT, "%05d", i % numUnique);
705+
doc.add(SortedDocValuesField.indexedField("val", new BytesRef(val)));
706+
w.addDocument(doc);
707+
}
708+
w.forceMerge(1);
709+
}
710+
711+
try (DirectoryReader reader = DirectoryReader.open(dir)) {
712+
LeafReaderContext ctx = reader.leaves().get(0);
713+
714+
// Slow path: per-doc evaluation via advanceExact + ordValue
715+
FixedBitSet expected = new FixedBitSet(numDocs);
716+
SortedDocValues slowDv = ctx.reader().getSortedDocValues("val");
717+
for (int d = 0; d < numDocs; d++) {
718+
if (slowDv.advanceExact(d)) {
719+
int ord = slowDv.ordValue();
720+
if (ord >= minOrd && ord <= maxOrd) {
721+
expected.set(d);
722+
}
723+
}
724+
}
725+
726+
// Fast path: DocValuesRangeIterator.forOrdinalRange with intoBitSet
727+
DocValuesSkipper skipper = ctx.reader().getDocValuesSkipper("val");
728+
SortedDocValues fastDv = ctx.reader().getSortedDocValues("val");
729+
assertNotNull("Field must have a skip index for randomized test", skipper);
730+
731+
DocValuesRangeIterator rangeIter =
732+
DocValuesRangeIterator.forOrdinalRange(fastDv, skipper, minOrd, maxOrd);
733+
rangeIter.approximation().nextDoc();
734+
735+
FixedBitSet actual = new FixedBitSet(numDocs);
736+
rangeIter.intoBitSet(numDocs, actual, 0);
737+
738+
assertEquals(
739+
"ordinal intoBitSet must match per-doc evaluation (numDocs="
740+
+ numDocs
741+
+ ", numUnique="
742+
+ numUnique
743+
+ ", ordRange=["
744+
+ minOrd
745+
+ ","
746+
+ maxOrd
747+
+ "])",
748+
expected,
749+
actual);
750+
}
751+
}
752+
}
753+
}
754+
582755
/**
583756
* Tests that rangeIntoBitSet (SIMD/fast path) produces the exact same results as per-doc
584757
* evaluation (slow path) across random data with various densities and range selectivities.

0 commit comments

Comments
 (0)