Only dedup query index matches when it can help - #1296
Merged
Merged
Conversation
DedupConsumer exists for :or clauses, where the same value is registered under more than one term and a traversal can reach it twice. Every lookup paid for it: a DedupConsumer plus a HashSet, and on the first delivery a 16 slot table and a node. For an index built only from :and queries none of that can ever suppress anything. Instrumenting the QueryIndexMatch workload, 5000 subscriptions matched against 200 ids, counted 932 deliveries and 0 suppressions. Track in add() whether a value was registered under more than one term and only wrap when it was. A value reachable from a single term can be found once, so there is nothing to dedup. The flag is set before the value is registered anywhere rather than after. Lookups run concurrently with updates, so setting it afterwards leaves a window where a lookup finds the value under two terms and still reads the old flag, delivering it twice. It counts the terms in the dnf list rather than the ones that end up registered, which over-approximates and only ever costs a dedup that was not needed. Sticky: a remove() can only make deduping unnecessary, never necessary, and recomputing would mean walking the index. That leaves the wrapper allocated for an index with any :or in it, so also defer its set until a second distinct value arrives, keeping the first in a field. Most lookups deliver nothing or one value. The workers now take a plain Consumer, since the wrapping decision is made once at the public entry rather than being carried in the parameter type, and DedupConsumer.from goes with it: the type it guarded against no longer reaches them. The forEachMatchImpl naming from Netflix#1295 is what keeps a recursive call from reaching the public method. QueryIndexMatch with the gc profiler, against 08ac1af: no :or in the index 126.1 -> 116.2 us/op 49953 -> 3201 B/op one :or among 5000 126.1 -> 121.5 us/op 49953 -> 39441 B/op The residual 3201 B/op over 200 lookups is the benchmark's own consumer lambda, so the index allocates nothing per lookup in the first case. The second is the limit of the flag: it is index wide, so one :or turns it off for everything and only the deferred set helps there.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
DedupConsumerexists for:orclauses, where the same value is registered under more than one DNF term and a traversal can reach it twice. Every lookup paid for it: aDedupConsumerplus aHashSet, and on the first delivery a 16-slot table and a node.For an index built only from
:andqueries none of that can ever suppress anything. Instrumenting theQueryIndexMatchworkload — 5,000 subscriptions matched against 200 ids — counted 932 deliveries and 0 suppressions.Two changes
Only wrap when it can help.
add()records whether a value was registered under more than one term; a value reachable from a single term can be found once, so there is nothing to dedup.The flag is set before the value is registered rather than after. Lookups run concurrently with updates —
Evaluator.sync()adds on the config thread whileupdate()runs on recording threads — so setting it afterwards leaves a window where a lookup finds the value under two terms and still reads the old flag, delivering it twice and double-counting that datapoint. It counts the terms in the dnf list rather than the ones that end up registered, which over-approximates and only ever costs a dedup that was not needed.Sticky: a
remove()can only make deduping unnecessary, never necessary, and recomputing would mean walking the index.Defer the set. That still leaves the wrapper allocated for an index with any
:orin it, soDedupConsumernow keeps the first value in a field and only builds the set when a second distinct value arrives. Most lookups deliver nothing or one value. AhasFirstboolean rather than a null sentinel, so a null value cannot be mistaken for an empty state.Numbers
QueryIndexMatchwith the gc profiler, against 08ac1af::orin the index:oramong 5,000The residual 3,201 B/op over 200 lookups is 16 B each — the benchmark's own
bh::consumelambda — so the index itself allocates nothing per lookup in the first case.The second row is the honest limit: the flag is index-wide, so a single
:orsubscription turns it off for everything and only the deferred set helps there. That is why both changes are here rather than just the flag.Also
The private workers now take
Consumer<T>rather thanDedupConsumer<T>, since the decision is made once at the public entry, andDedupConsumer.fromgoes with it — the type it guarded against no longer reaches them. TheforEachMatchImplnaming from #1295 is what keeps a recursive call from reaching the public method and re-wrapping.add()'s javadoc now records that the guarantee is per call: adding the same value, or twoequalsvalues, under separate single-term calls can deliver it once for each. No in-repo caller does this, but nothing enforced it either.Tests
Four cases, and each branch of the change has one that fails when it breaks — hard-wiring the flag off fails 4, always promoting to the set fails 3, and never suppressing fails 1.
dedupHandlesASecondValueAfterTheFirstis the one worth explaining. The carry of the first value into the set only matters when that value is delivered again after the set exists, which needs the delivery orderX, Y, X. My first two attempts at it producedX, X, Yand passed with the carry deleted; the construction that works has the second query share a leaf with the:or's first branch.Known limit, not addressed
terms > 1counts registrations, not co-reachable leaves. Terms pinning different:eqvalues for the same key —name,a,:eq,name,b,:eq,:or, the shape of the existingqueryNormalizationtest — can never co-match, yet turn the flag on for the whole index. Refining that means comparingQuery.exactTags()across terms to rule out co-matching; it would widen where the win applies but is a separate change.