Skip to content

Commit a759a95

Browse files
authored
Optimize cached-filter conjunction batching (#16146)
Speed up no-score filtered-optional Boolean queries by routing dense conjunctions with cached FixedBitSet filters through ConstantScoreBulkScorer, which batches matches in bulk via DocIdStream.
1 parent 3a63f5e commit a759a95

9 files changed

Lines changed: 1112 additions & 9 deletions

File tree

lucene/CHANGES.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,9 @@ Optimizations
298298
---------------------
299299
* GITHUG#16280: Single-pass writeString fast path for short strings in ByteBuffersDataOutput (neoremind)
300300

301+
* GITHUB#16146: Speed up no-score filtered-optional Boolean queries by routing dense conjunctions with cached FixedBitSet filters
302+
through ConstantScoreBulkScorer, which batches matches in bulk via DocIdStream. (Costin Leau)
303+
301304
Bug Fixes
302305
---------------------
303306
(No changes)
Lines changed: 376 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,376 @@
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.nio.file.Files;
21+
import java.nio.file.Path;
22+
import java.util.Comparator;
23+
import java.util.Random;
24+
import java.util.concurrent.TimeUnit;
25+
import java.util.stream.Stream;
26+
import org.apache.lucene.document.Document;
27+
import org.apache.lucene.document.Field.Store;
28+
import org.apache.lucene.document.StringField;
29+
import org.apache.lucene.index.DirectoryReader;
30+
import org.apache.lucene.index.IndexReader;
31+
import org.apache.lucene.index.IndexWriter;
32+
import org.apache.lucene.index.IndexWriterConfig;
33+
import org.apache.lucene.index.LeafReaderContext;
34+
import org.apache.lucene.index.Term;
35+
import org.apache.lucene.search.BooleanClause.Occur;
36+
import org.apache.lucene.search.BooleanQuery;
37+
import org.apache.lucene.search.BulkScorer;
38+
import org.apache.lucene.search.DocIdSetIterator;
39+
import org.apache.lucene.search.DocIdStream;
40+
import org.apache.lucene.search.IndexSearcher;
41+
import org.apache.lucene.search.LRUQueryCache;
42+
import org.apache.lucene.search.LeafCollector;
43+
import org.apache.lucene.search.Query;
44+
import org.apache.lucene.search.QueryCachingPolicy;
45+
import org.apache.lucene.search.Scorable;
46+
import org.apache.lucene.search.ScoreMode;
47+
import org.apache.lucene.search.Scorer;
48+
import org.apache.lucene.search.ScorerSupplier;
49+
import org.apache.lucene.search.TermQuery;
50+
import org.apache.lucene.search.Weight;
51+
import org.apache.lucene.store.Directory;
52+
import org.apache.lucene.store.MMapDirectory;
53+
import org.openjdk.jmh.annotations.Benchmark;
54+
import org.openjdk.jmh.annotations.BenchmarkMode;
55+
import org.openjdk.jmh.annotations.Fork;
56+
import org.openjdk.jmh.annotations.Level;
57+
import org.openjdk.jmh.annotations.Measurement;
58+
import org.openjdk.jmh.annotations.Mode;
59+
import org.openjdk.jmh.annotations.OutputTimeUnit;
60+
import org.openjdk.jmh.annotations.Param;
61+
import org.openjdk.jmh.annotations.Scope;
62+
import org.openjdk.jmh.annotations.Setup;
63+
import org.openjdk.jmh.annotations.State;
64+
import org.openjdk.jmh.annotations.TearDown;
65+
import org.openjdk.jmh.annotations.Warmup;
66+
67+
/**
68+
* Benchmarks cached-filter conjunctions on no-score Boolean queries.
69+
*
70+
* <p>The filter is warmed into {@link LRUQueryCache} before measurement. Dense filter selectivities
71+
* are above the cache's FixedBitSet threshold, while very sparse selectivities exercise the
72+
* RoaringDocIdSet cache path.
73+
*
74+
* <p>The {@code filtered_optional} shape exercises the production path where a cached filter is
75+
* combined with a sparse optional disjunction through {@code
76+
* BooleanScorerSupplier.filteredOptionalBulkScorer()}, which can route to {@code
77+
* ConstantScoreBulkScorer}. The {@code filter_only} shape is retained as a control for the older
78+
* pure-filter conjunction shape. Very sparse filters below the query-cache bitset threshold and
79+
* very dense optional leads are controls too: they exercise RoaringDocIdSet and
80+
* DenseConjunctionBulkScorer paths rather than {@code BitSetConjunctionDISI.intoBitSet()}.
81+
*/
82+
@State(Scope.Thread)
83+
@BenchmarkMode(Mode.Throughput)
84+
@OutputTimeUnit(TimeUnit.SECONDS)
85+
@Warmup(iterations = 3, time = 3)
86+
@Measurement(iterations = 5, time = 5)
87+
@Fork(
88+
value = 1,
89+
warmups = 1,
90+
jvmArgsAppend = {"-Xmx2g", "-Xms2g", "-XX:+AlwaysPreTouch"})
91+
public class CachedFilterConjunctionBenchmark {
92+
93+
private static final String FILTER_FIELD = "filter";
94+
private static final String LEAD_FIELD = "lead";
95+
private static final String OPTIONAL_FIELD = "optional";
96+
private static final String OPTIONAL_A = "a";
97+
private static final String OPTIONAL_B = "b";
98+
private static final String YES = "yes";
99+
private static final String BASELINE = "baseline";
100+
private static final String BULKSCORER = "bulkscorer";
101+
private static final String FILTER_ONLY = "filter_only";
102+
private static final String FILTERED_OPTIONAL = "filtered_optional";
103+
104+
private Directory dir;
105+
private IndexReader reader;
106+
private Path path;
107+
private IndexSearcher cachedSearcher;
108+
private IndexSearcher uncachedSearcher;
109+
private LRUQueryCache queryCache;
110+
private Query filterQuery;
111+
private Query conjunctionQuery;
112+
private Query filterOnlyQuery;
113+
private Query filteredOptionalQuery;
114+
private LeafReaderContext context;
115+
private Weight cachedWeight;
116+
private Weight uncachedWeight;
117+
private boolean cachedFilterUsesBitSet;
118+
private int expectedHitCount;
119+
120+
@State(Scope.Benchmark)
121+
public static class Params {
122+
@Param({"1000000"})
123+
public int docCount;
124+
125+
@Param({"0.0001", "0.001", "0.002", "0.003", "0.005", "0.01", "0.03", "0.10", "0.50", "1.0"})
126+
public double filterSelectivity;
127+
128+
@Param({"0.001", "0.03", "0.10"})
129+
public double leadSelectivity;
130+
131+
@Param({FILTERED_OPTIONAL, FILTER_ONLY})
132+
public String queryShape;
133+
134+
@Param({BASELINE, BULKSCORER})
135+
public String variant;
136+
}
137+
138+
@Setup(Level.Trial)
139+
public void setup(Params params) throws Exception {
140+
path = Files.createTempDirectory("cachedFilterConjunctionBench");
141+
dir = MMapDirectory.open(path);
142+
143+
IndexWriter w = new IndexWriter(dir, new IndexWriterConfig());
144+
Random random = new Random(42);
145+
for (int i = 0; i < params.docCount; ++i) {
146+
Document doc = new Document();
147+
if (random.nextDouble() < params.filterSelectivity) {
148+
doc.add(new StringField(FILTER_FIELD, YES, Store.NO));
149+
}
150+
if (random.nextDouble() < params.leadSelectivity) {
151+
doc.add(new StringField(LEAD_FIELD, YES, Store.NO));
152+
if (random.nextBoolean()) {
153+
doc.add(new StringField(OPTIONAL_FIELD, OPTIONAL_A, Store.NO));
154+
} else {
155+
doc.add(new StringField(OPTIONAL_FIELD, OPTIONAL_B, Store.NO));
156+
}
157+
}
158+
w.addDocument(doc);
159+
}
160+
w.forceMerge(1);
161+
reader = DirectoryReader.open(w);
162+
w.close();
163+
164+
filterQuery = new TermQuery(new Term(FILTER_FIELD, YES));
165+
Query leadQuery = new TermQuery(new Term(LEAD_FIELD, YES));
166+
filterOnlyQuery =
167+
new BooleanQuery.Builder()
168+
.add(leadQuery, Occur.FILTER)
169+
.add(filterQuery, Occur.FILTER)
170+
.build();
171+
filteredOptionalQuery =
172+
new BooleanQuery.Builder()
173+
.add(filterQuery, Occur.FILTER)
174+
.add(new TermQuery(new Term(OPTIONAL_FIELD, OPTIONAL_A)), Occur.SHOULD)
175+
.add(new TermQuery(new Term(OPTIONAL_FIELD, OPTIONAL_B)), Occur.SHOULD)
176+
.setMinimumNumberShouldMatch(1)
177+
.build();
178+
conjunctionQuery =
179+
switch (params.queryShape) {
180+
case FILTER_ONLY -> filterOnlyQuery;
181+
case FILTERED_OPTIONAL -> filteredOptionalQuery;
182+
default -> throw new AssertionError("Unknown query shape: " + params.queryShape);
183+
};
184+
185+
queryCache = new LRUQueryCache(256, 32L * 1024 * 1024, context -> context.reader() != null, 1f);
186+
cachedSearcher = new IndexSearcher(reader);
187+
cachedSearcher.setQueryCache(queryCache);
188+
cachedSearcher.setQueryCachingPolicy(cacheOnly(filterQuery));
189+
190+
uncachedSearcher = new IndexSearcher(reader);
191+
uncachedSearcher.setQueryCache(null);
192+
193+
context = reader.leaves().get(0);
194+
195+
// Warm only the filter query so the benchmark measures reuse of a cached FixedBitSet filter,
196+
// not first-use cache population or a fully cached top-level conjunction.
197+
Weight filterWeight =
198+
cachedSearcher.createWeight(
199+
cachedSearcher.rewrite(filterQuery), ScoreMode.COMPLETE_NO_SCORES, 1f);
200+
ScorerSupplier filterScorerSupplier = filterWeight.scorerSupplier(context);
201+
if (filterScorerSupplier != null) {
202+
cachedFilterUsesBitSet = filterScorerSupplier.cost() * 100 >= context.reader().maxDoc();
203+
filterScorerSupplier.get(Long.MAX_VALUE).iterator().nextDoc();
204+
}
205+
if (queryCache.getCacheSize() == 0) {
206+
throw new AssertionError("filter query was not cached");
207+
}
208+
209+
cachedWeight =
210+
cachedSearcher.createWeight(
211+
cachedSearcher.rewrite(conjunctionQuery), ScoreMode.COMPLETE_NO_SCORES, 1f);
212+
uncachedWeight =
213+
uncachedSearcher.createWeight(
214+
uncachedSearcher.rewrite(conjunctionQuery), ScoreMode.COMPLETE_NO_SCORES, 1f);
215+
expectedHitCount = uncachedSearcher.count(conjunctionQuery);
216+
checkBulkScorerRoute(params, cachedWeight);
217+
checkHitCount(scoreVariant(cachedWeight, params.variant));
218+
checkHitCount(scoreVariant(uncachedWeight, params.variant));
219+
}
220+
221+
private static QueryCachingPolicy cacheOnly(Query queryToCache) {
222+
return new QueryCachingPolicy() {
223+
@Override
224+
public void onUse(Query query) {}
225+
226+
@Override
227+
public boolean shouldCache(Query query) {
228+
return queryToCache.equals(query);
229+
}
230+
};
231+
}
232+
233+
@Benchmark
234+
public int countCachedFilterConjunction(Params params) throws IOException {
235+
return scoreVariant(cachedWeight, params.variant);
236+
}
237+
238+
@Benchmark
239+
public int countUncachedFilterConjunction(Params params) throws IOException {
240+
return scoreVariant(uncachedWeight, params.variant);
241+
}
242+
243+
private int scoreVariant(Weight weight, String variant) throws IOException {
244+
switch (variant) {
245+
case BASELINE:
246+
Scorer scorer = scorer(weight);
247+
if (scorer == null) {
248+
return 0;
249+
}
250+
return scorePerDoc(scorer);
251+
case BULKSCORER:
252+
return scoreBulkScorer(weight);
253+
default:
254+
throw new AssertionError("Unknown variant: " + variant);
255+
}
256+
}
257+
258+
private Scorer scorer(Weight weight) throws IOException {
259+
ScorerSupplier scorerSupplier = weight.scorerSupplier(context);
260+
if (scorerSupplier == null) {
261+
return null;
262+
}
263+
return scorerSupplier.get(Long.MAX_VALUE);
264+
}
265+
266+
private int scorePerDoc(Scorer scorer) throws IOException {
267+
DocIdSetIterator iterator = scorer.iterator();
268+
int maxDoc = context.reader().maxDoc();
269+
return scorePerDoc(iterator, maxDoc);
270+
}
271+
272+
private int scorePerDoc(DocIdSetIterator iterator, int maxDoc) throws IOException {
273+
CountingLeafCollector collector = new CountingLeafCollector();
274+
for (int doc = iterator.nextDoc(); doc < maxDoc; doc = iterator.nextDoc()) {
275+
collector.collect(doc);
276+
}
277+
return collector.count;
278+
}
279+
280+
private int scoreBulkScorer(Weight weight) throws IOException {
281+
BulkScorer bulkScorer = weight.bulkScorer(context);
282+
if (bulkScorer == null) {
283+
return 0;
284+
}
285+
CountingLeafCollector collector = new CountingLeafCollector();
286+
bulkScorer.score(collector, context.reader().getLiveDocs(), 0, context.reader().maxDoc());
287+
return collector.count;
288+
}
289+
290+
private void checkBulkScorerRoute(Params params, Weight weight) throws IOException {
291+
ScorerSupplier scorerSupplier = weight.scorerSupplier(context);
292+
if (scorerSupplier == null) {
293+
return;
294+
}
295+
BulkScorer bulkScorer = scorerSupplier.bulkScorer();
296+
String bulkScorerClassName = bulkScorer.getClass().getName();
297+
boolean isConstantScoreBulkScorer = bulkScorerClassName.endsWith(".ConstantScoreBulkScorer");
298+
boolean isDenseConjunctionBulkScorer =
299+
bulkScorerClassName.endsWith(".DenseConjunctionBulkScorer");
300+
boolean isDefaultBulkScorer = bulkScorerClassName.endsWith("$DefaultBulkScorer");
301+
if (FILTERED_OPTIONAL.equals(params.queryShape)
302+
&& isConstantScoreBulkScorer == false
303+
&& isDenseConjunctionBulkScorer == false
304+
&& isDefaultBulkScorer == false) {
305+
throw new AssertionError(
306+
"filtered_optional should route through ConstantScoreBulkScorer, "
307+
+ "DenseConjunctionBulkScorer, or DefaultBulkScorer but got "
308+
+ bulkScorerClassName);
309+
}
310+
if (FILTERED_OPTIONAL.equals(params.queryShape)
311+
&& isConstantScoreBulkScorer
312+
&& cachedFilterUsesBitSet
313+
&& params.filterSelectivity >= 0.02) {
314+
Scorer scorer = scorerSupplier.get(Long.MAX_VALUE);
315+
String iteratorClassName = scorer.iterator().getClass().getName();
316+
if (iteratorClassName.endsWith("$BitSetConjunctionDISI") == false) {
317+
throw new AssertionError(
318+
"cached filter should produce BitSetConjunctionDISI but got " + iteratorClassName);
319+
}
320+
}
321+
if (FILTER_ONLY.equals(params.queryShape) && isConstantScoreBulkScorer) {
322+
throw new AssertionError("filter_only unexpectedly routed through ConstantScoreBulkScorer");
323+
}
324+
}
325+
326+
private void checkHitCount(int hitCount) {
327+
if (hitCount != expectedHitCount) {
328+
throw new AssertionError("hitCount=" + hitCount + " expected=" + expectedHitCount);
329+
}
330+
}
331+
332+
private static class CountingLeafCollector implements LeafCollector {
333+
int count;
334+
335+
@Override
336+
public void setScorer(Scorable scorer) {}
337+
338+
@Override
339+
public void collect(int doc) {
340+
++count;
341+
}
342+
343+
@Override
344+
public void collect(DocIdStream stream) throws IOException {
345+
// Match TotalHitCountCollector's batch path.
346+
for (int streamCount = stream.count(); streamCount != 0; streamCount = stream.count()) {
347+
count += streamCount;
348+
}
349+
}
350+
}
351+
352+
@TearDown(Level.Trial)
353+
public void tearDown() throws IOException {
354+
if (queryCache != null) {
355+
queryCache.close();
356+
}
357+
if (reader != null) {
358+
reader.close();
359+
}
360+
if (dir != null) {
361+
dir.close();
362+
}
363+
if (path != null && Files.exists(path)) {
364+
try (Stream<Path> walk = Files.walk(path)) {
365+
walk.sorted(Comparator.reverseOrder())
366+
.forEach(
367+
p -> {
368+
try {
369+
Files.delete(p);
370+
} catch (IOException _) {
371+
}
372+
});
373+
}
374+
}
375+
}
376+
}

0 commit comments

Comments
 (0)