Skip to content

Commit 75f6e5d

Browse files
costinromseygeek
authored andcommitted
Optimize encoded numeric range bitsets (#16160)
GCD- and delta-encoded dense NumericDocValues can reuse the existing range-into-bitset fast path by transforming query bounds into the encoded domain once per call. Open bounds are saturated so they keep the SIMD path even when the bound transformation would otherwise overflow.
1 parent 8e40c91 commit 75f6e5d

4 files changed

Lines changed: 442 additions & 7 deletions

File tree

lucene/CHANGES.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ Optimizations
3131
* GITHUB#16316: Lazily allocate buffers in MaxScoreBulkScorer. These are now only allocated when going
3232
via code paths that actually use them. (Alan Woodward)
3333

34+
* GITHUB#16160: Improve numeric doc values range query performance for dense fields that use GCD or delta encoding. (Costin Leau)
35+
3436
Bug Fixes
3537
---------------------
3638
(No changes)
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.apache.lucene.benchmark.jmh;
18+
19+
import java.io.IOException;
20+
import java.io.UncheckedIOException;
21+
import java.nio.file.Files;
22+
import java.nio.file.Path;
23+
import java.util.Comparator;
24+
import java.util.Random;
25+
import java.util.concurrent.TimeUnit;
26+
import java.util.stream.Stream;
27+
import org.apache.lucene.document.Document;
28+
import org.apache.lucene.document.Field;
29+
import org.apache.lucene.document.NumericDocValuesField;
30+
import org.apache.lucene.document.SortedNumericDocValuesField;
31+
import org.apache.lucene.document.StringField;
32+
import org.apache.lucene.index.DirectoryReader;
33+
import org.apache.lucene.index.IndexWriter;
34+
import org.apache.lucene.index.IndexWriterConfig;
35+
import org.apache.lucene.index.Term;
36+
import org.apache.lucene.search.BooleanClause.Occur;
37+
import org.apache.lucene.search.BooleanQuery;
38+
import org.apache.lucene.search.IndexSearcher;
39+
import org.apache.lucene.search.Query;
40+
import org.apache.lucene.search.TermQuery;
41+
import org.apache.lucene.store.Directory;
42+
import org.apache.lucene.store.MMapDirectory;
43+
import org.openjdk.jmh.annotations.Benchmark;
44+
import org.openjdk.jmh.annotations.BenchmarkMode;
45+
import org.openjdk.jmh.annotations.Fork;
46+
import org.openjdk.jmh.annotations.Level;
47+
import org.openjdk.jmh.annotations.Measurement;
48+
import org.openjdk.jmh.annotations.Mode;
49+
import org.openjdk.jmh.annotations.OutputTimeUnit;
50+
import org.openjdk.jmh.annotations.Param;
51+
import org.openjdk.jmh.annotations.Scope;
52+
import org.openjdk.jmh.annotations.Setup;
53+
import org.openjdk.jmh.annotations.State;
54+
import org.openjdk.jmh.annotations.TearDown;
55+
import org.openjdk.jmh.annotations.Warmup;
56+
57+
/** Benchmarks range queries over dense numeric doc values encoded as raw, delta, GCD, or both. */
58+
@State(Scope.Thread)
59+
@BenchmarkMode(Mode.Throughput)
60+
@OutputTimeUnit(TimeUnit.SECONDS)
61+
@Warmup(iterations = 3, time = 3)
62+
@Measurement(iterations = 5, time = 5)
63+
public class GcdDeltaRangeIntoBitSetBenchmark {
64+
65+
private static final String FIELD = "val";
66+
private static final String LEAD_FIELD = "lead";
67+
private static final String LEAD_VALUE = "yes";
68+
private static final String NONE = "none";
69+
private static final String DELTA_ONLY = "delta_only";
70+
private static final String GCD_1000 = "gcd_1000";
71+
private static final String GCD_100_DELTA = "gcd_100_delta";
72+
private static final long DOMAIN = 10_000_000L;
73+
private static final long DELTA = 1_700_000_000_000L;
74+
75+
private Directory dir;
76+
private DirectoryReader reader;
77+
private IndexSearcher searcher;
78+
private Path path;
79+
private Query query;
80+
81+
@Param({"1000000"})
82+
public int numDocs;
83+
84+
@Param({NONE, DELTA_ONLY, GCD_1000, GCD_100_DELTA})
85+
public String encoding;
86+
87+
@Param({"0.01", "0.1", "0.5"})
88+
public double selectivity;
89+
90+
@Setup(Level.Trial)
91+
public void setup() throws Exception {
92+
path = Files.createTempDirectory("gcdDeltaRangeIntoBitSet");
93+
dir = MMapDirectory.open(path);
94+
95+
Random random = new Random(0);
96+
try (IndexWriter writer = new IndexWriter(dir, new IndexWriterConfig())) {
97+
for (int i = 0; i < numDocs; i++) {
98+
Document doc = new Document();
99+
doc.add(NumericDocValuesField.indexedField(FIELD, valueForDoc(encoding, i, random)));
100+
doc.add(new StringField(LEAD_FIELD, LEAD_VALUE, Field.Store.NO));
101+
writer.addDocument(doc);
102+
}
103+
writer.forceMerge(1);
104+
}
105+
106+
reader = DirectoryReader.open(dir);
107+
searcher = new IndexSearcher(reader);
108+
query = rangeQuery(encoding, selectivity);
109+
}
110+
111+
private static long valueForDoc(String encoding, int doc, Random random) {
112+
if (doc == 0) {
113+
return minimumValue(encoding);
114+
} else if (doc == 1 && encoding.equals(GCD_100_DELTA)) {
115+
// Anchor entry.gcd to exactly 100 for the GCD_100_DELTA encoding: random multiples of 100
116+
// could otherwise share a larger common factor under some seeds, which would change the
117+
// shape of the encoded values and what the benchmark measures.
118+
return DELTA + 100L;
119+
}
120+
121+
long value = random.nextLong(0, DOMAIN);
122+
switch (encoding) {
123+
case NONE:
124+
return value;
125+
case DELTA_ONLY:
126+
return DELTA + value;
127+
case GCD_1000:
128+
return value * 1_000L;
129+
case GCD_100_DELTA:
130+
return DELTA + value * 100L;
131+
default:
132+
throw new IllegalArgumentException("Unknown encoding: " + encoding);
133+
}
134+
}
135+
136+
private static long minimumValue(String encoding) {
137+
switch (encoding) {
138+
case NONE:
139+
case GCD_1000:
140+
return 0;
141+
case DELTA_ONLY:
142+
case GCD_100_DELTA:
143+
return DELTA;
144+
default:
145+
throw new IllegalArgumentException("Unknown encoding: " + encoding);
146+
}
147+
}
148+
149+
private static Query rangeQuery(String encoding, double selectivity) {
150+
long range = Math.max(1, (long) (DOMAIN * selectivity));
151+
long min = (DOMAIN - range) / 2;
152+
long max = min + range;
153+
Query rangeQuery =
154+
SortedNumericDocValuesField.newSlowRangeQuery(
155+
FIELD, actualValue(encoding, min), actualValue(encoding, max));
156+
return new BooleanQuery.Builder()
157+
.add(new TermQuery(new Term(LEAD_FIELD, LEAD_VALUE)), Occur.FILTER)
158+
.add(rangeQuery, Occur.FILTER)
159+
.build();
160+
}
161+
162+
private static long actualValue(String encoding, long value) {
163+
switch (encoding) {
164+
case NONE:
165+
return value;
166+
case DELTA_ONLY:
167+
return DELTA + value;
168+
case GCD_1000:
169+
return value * 1_000L;
170+
case GCD_100_DELTA:
171+
return DELTA + value * 100L;
172+
default:
173+
throw new IllegalArgumentException("Unknown encoding: " + encoding);
174+
}
175+
}
176+
177+
@TearDown(Level.Trial)
178+
public void tearDown() throws Exception {
179+
reader.close();
180+
dir.close();
181+
if (Files.exists(path)) {
182+
try (Stream<Path> walk = Files.walk(path)) {
183+
walk.sorted(Comparator.reverseOrder())
184+
.forEach(
185+
p -> {
186+
try {
187+
Files.delete(p);
188+
} catch (IOException e) {
189+
throw new UncheckedIOException(e);
190+
}
191+
});
192+
}
193+
}
194+
}
195+
196+
@Benchmark
197+
@Fork(
198+
value = 1,
199+
jvmArgsAppend = {"-Xmx2g", "-Xms2g", "-XX:+AlwaysPreTouch"})
200+
public int rangeQueryDefaultProvider() throws IOException {
201+
return searcher.count(query);
202+
}
203+
204+
@Benchmark
205+
@Fork(
206+
value = 1,
207+
jvmArgsAppend = {
208+
"--add-modules",
209+
"jdk.incubator.vector",
210+
"-Xmx2g",
211+
"-Xms2g",
212+
"-XX:+AlwaysPreTouch"
213+
})
214+
public int rangeQueryPanamaProvider() throws IOException {
215+
return searcher.count(query);
216+
}
217+
}

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

Lines changed: 68 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,72 @@ static void rangeIntoBitSet(
484484
values, fromDoc, toDoc, minValue, maxValue, bitSet, offset);
485485
}
486486

487+
/**
488+
* Maps the raw query bounds {@code [minValue, maxValue]} into the encoded domain so the SIMD
489+
* kernel in {@link org.apache.lucene.internal.vectorization.DocValuesRangeSupport} can run
490+
* directly on packed values: {@code [encodedMin, encodedMax] = [ceil((min - delta) / mul),
491+
* floor((max - delta) / mul)]}. Open bounds (e.g. {@code Long.MIN_VALUE} or {@code
492+
* Long.MAX_VALUE}) are saturated to the encoded domain so they keep the SIMD path even when
493+
* {@code min - delta} or {@code max - delta} would overflow.
494+
*/
495+
private static void rangeGcdDeltaIntoBitSet(
496+
LongValues values,
497+
int fromDoc,
498+
int toDoc,
499+
long minValue,
500+
long maxValue,
501+
long mul,
502+
long delta,
503+
FixedBitSet bitSet,
504+
int offset) {
505+
assert mul > 0;
506+
long encodedMin = saturatingShiftLower(minValue, delta);
507+
long encodedMax = saturatingShiftUpper(maxValue, delta);
508+
if (mul != 1) {
509+
// Math.ceilDiv / Math.floorDiv never overflow for mul > 0 (only Long.MIN_VALUE / -1 does),
510+
// so the SIMD path is always taken; no fallback to the per-doc decoded loop is required.
511+
encodedMin = Math.ceilDiv(encodedMin, mul);
512+
encodedMax = Math.floorDiv(encodedMax, mul);
513+
}
514+
encodedMin = Math.max(0, encodedMin);
515+
if (encodedMin <= encodedMax) {
516+
rangeIntoBitSet(values, fromDoc, toDoc, encodedMin, encodedMax, bitSet, offset);
517+
}
518+
}
519+
520+
/**
521+
* Returns {@code minValue - delta}, saturating to {@code Long.MIN_VALUE} when the real value
522+
* would underflow (every non-negative stored value satisfies the lower bound) or to {@code
523+
* Long.MAX_VALUE} when it would overflow (no stored value can satisfy the lower bound). Stored
524+
* values are non-negative, so the caller can keep using the SIMD path with these saturated
525+
* sentinels.
526+
*/
527+
private static long saturatingShiftLower(long minValue, long delta) {
528+
try {
529+
return Math.subtractExact(minValue, delta);
530+
} catch (
531+
@SuppressWarnings("unused")
532+
ArithmeticException overflow) {
533+
return delta > 0 ? Long.MIN_VALUE : Long.MAX_VALUE;
534+
}
535+
}
536+
537+
/**
538+
* Symmetric counterpart of {@link #saturatingShiftLower}: returns {@code maxValue - delta},
539+
* saturating to {@code Long.MAX_VALUE} when the real value would overflow (every stored value
540+
* satisfies the upper bound) or to {@code Long.MIN_VALUE} when it would underflow (no stored
541+
* value can satisfy the upper bound).
542+
*/
543+
private static long saturatingShiftUpper(long maxValue, long delta) {
544+
try {
545+
return Math.subtractExact(maxValue, delta);
546+
} catch (
547+
@SuppressWarnings("unused")
548+
ArithmeticException overflow) {
549+
return delta < 0 ? Long.MAX_VALUE : Long.MIN_VALUE;
550+
}
551+
}
552+
487553
private static int fixedCardinality(
488554
SortedNumericEntry entry, DocValuesSkipperEntry skipperEntry) {
489555
if (skipperEntry == null
@@ -963,13 +1029,8 @@ public void rangeIntoBitSet(
9631029
long maxValue,
9641030
org.apache.lucene.util.FixedBitSet bitSet,
9651031
int offset) {
966-
// Per-doc evaluation for gcd/delta encoded fields
967-
for (int d = fromDoc; d < toDoc; d++) {
968-
long v = mul * values.get(d) + delta;
969-
if (v >= minValue && v <= maxValue) {
970-
bitSet.set(d - offset);
971-
}
972-
}
1032+
Lucene90DocValuesProducer.rangeGcdDeltaIntoBitSet(
1033+
values, fromDoc, toDoc, minValue, maxValue, mul, delta, bitSet, offset);
9731034
}
9741035
};
9751036
}

0 commit comments

Comments
 (0)