Skip to content

Enable simd numeric range usage for not-equals queries - #16568

Draft
parkertimmins wants to merge 5 commits into
apache:mainfrom
parkertimmins:not-equals-numeric-range-benchmark
Draft

Enable simd numeric range usage for not-equals queries#16568
parkertimmins wants to merge 5 commits into
apache:mainfrom
parkertimmins:not-equals-numeric-range-benchmark

Conversation

@parkertimmins

@parkertimmins parkertimmins commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

#16050 added numeric range queries to make use of SIMD. #16307 added usage of docIDRunEnd() to ReqExclBulkScorer, allowing must_not queries to make use of the bulk logic in docIDRunEnd(). This is called on the excluded TwoPhaseIterator, allowing it to quickly skip a large group of excluded docs. Because of this, queries with lots of excluded docs are efficient.

But queries with few excluded docs cannot use docIDRunEnd() bulk logic and fall back to per-element checking. This can be seen in this benchmark. It tests a numeric range query with either a filter or a must_not. The selectivity is how many of the filter queries matched. For the must_not query it's now the percent that didn't match. (So techniquely it's 1-selectivity).

Selectivity Filter default MUST_NOT default Filter Panama MUST_NOT Panama
0.01 236.786 ± 5.855 42.162 ± 5.477 545.464 ± 15.000 44.303 ± 2.776
0.10 200.210 ± 21.402 43.665 ± 4.309 337.842 ± 5.168 44.643 ± 0.734
0.50 202.529 ± 1.772 56.723 ± 5.886 470.476 ± 24.148 56.038 ± 0.550
0.90 549.659 ± 69.162 130.545 ± 1.347 517.654 ± 24.159 136.902 ± 8.890
0.99 778.650 ± 75.649 205.053 ± 21.086 523.284 ± 8.663 204.756 ± 14.010

The importantly thing to note are that:

  1. must_not numeric range queries are slow when the excluded filter matches few docs
  2. must_not numeric range queries do not take advantage of simd
    The reason for 1. is because as the exclude filter matches more documents, docIDRunEnd() can be used more. (At least I think that's why.)

Which brings us to this PR. We want to be able to take advantage of bulk logic for must_not queries. Here are the results from the change in this PR:

selectivity Filter default MUST_NOT default Filter Panama MUST_NOT Panama
0.01 223.925 ± 22.893 136.798 ± 15.561 554.050 ± 21.567 560.875 ± 12.667
0.10 208.719 ± 2.886 124.365 ± 10.191 352.506 ± 47.884 349.954 ± 12.874
0.50 221.852 ± 10.643 136.318 ± 9.214 475.306 ± 35.557 466.713 ± 47.488
0.90 559.933 ± 7.561 249.627 ± 20.743 539.056 ± 5.218 524.616 ± 59.354
0.99 756.443 ± 89.497 309.867 ± 5.431 520.140 ± 52.677 534.437 ± 11.854

Introduces NegationIterator, a DocIdSetIterator that inverts any excluded
clause, enabling DenseConjunctionBulkScorer to handle MUST_NOT via bitset
intersection rather than ReqExclBulkScorer's per-document exclusion loop.

Key optimizations exposed through NegationIterator:
- docIDRunEnd() returns the next excluded document position, so large
  gaps between excluded docs (NO blocks) become collectRange calls —
  O(1) for TotalHitCountCollector instead of traversing every hit.
- intoBitSet() sets all window bits then AND-NOTs the excluded clause's
  intoBitSet result. For numeric range queries this calls rangeIntoBitSet,
  enabling SIMD acceleration via the Panama vector API on MAYBE blocks.

BooleanScorerSupplier.negatedRequiredBulkScorer() applies this path when
the required side is dense FILTER-only (no MUST clauses, no scoring
needed, and lead cost meets DenseConjunctionBulkScorer's density
threshold). Falls back to ReqExclBulkScorer otherwise.

Benchmark results (1M docs, numeric range MUST_NOT, default provider):
  selectivity=0.01: 32.6 → 175.9 ops/s  (+5.4x)
  selectivity=0.1:  33.0 → 154.6 ops/s  (+4.7x)
  selectivity=0.5:  46.6 → 174.4 ops/s  (+3.7x)
  selectivity=0.9:  ~33  → 474.5 ops/s
  selectivity=0.99: ~33  → 605.2 ops/s

With Panama provider, notEquals now matches filterRange performance
at all selectivities (e.g. 32.7 → 412.8 ops/s at selectivity=0.01).
NegationTwoPhaseIterator uses DocIdSetIterator.all as its approximation,
making advance() O(1) regardless of what the excluded clause looks like.
This is slicing-safe: each partition's advance to its start position
never iterates through excluded documents.

DenseConjunctionBulkScorer calls applyMask() for TwoPhaseIterator
clauses (rather than intoBitSet), which removes excluded docs directly
from the surviving candidate set. The SIMD subtraction via
exclTwoPhase.intoBitSet() / rangeIntoBitSet is preserved.

The sorting rule in DenseConjunctionBulkScorer places TwoPhaseIterators
after plain DISIs, so the lead clause is always a plain DISI (e.g. the
dense term filter), and NegationTwoPhaseIterator never races it for lead
position.

docIDRunEnd() still exposes NO blocks (gaps before the excluded range)
to enable the collectRange shortcut for large unexcluded spans.
Two bugs caused a 2-5x slowdown vs the deleted NegationIterator:

1. Missing intoBitSet() override. When NegationTwoPhaseIterator is the
   lead clause in DenseConjunctionBulkScorer (which happens when the dense
   term query's docIDRunEnd() spans the whole window), the scorer calls
   lead.intoBitSet() on it. Without an override this fell back to
   TwoPhaseIterator's default per-doc matches() loop, bypassing SIMD
   entirely and explaining the flat Panama vs default numbers.

2. docIDRunEnd() never advanced exclApprox. The excluded approximation
   was only positioned inside applyMask(), so docIDRunEnd() always saw a
   stale position and returned doc+1, preventing the scorer from detecting
   NO-block spans and using the collectRange shortcut at high selectivity.

After the fix notEquals throughput matches filterRange at all selectivities.
Extract shared scratch-fill/andNot/clear logic from intoBitSet() and
applyMask() into a private subtractExcluded() helper, eliminating the
duplication between the two methods.

Rename TestNegationIterator to TestNegationTwoPhaseIterator and expand
coverage: randomised correctness test for the TwoPhaseIterator excluded
path (including approximation false positives), direct tests for
intoBitSet() and applyMask(), docIDRunEnd() edge cases (NO-block with
fresh iterator, excl at current doc), all-excluded and none-excluded
edge cases, and a test verifying the scoring fallback to ReqExclBulkScorer.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant