|
| 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