Repository navigation
perf(planner): run a bounded correlated block once as a key-set anti-join - #973
Merged
Merged
Conversation
…join A correlated block whose inner pattern reaches the outer row only through one binding's identity, and binds one of its own nodes by a key or unique equality or a list membership, now has a second inner tree: the same pattern rooted at that node, each Expand toward it reversed, ending on the correlation binding's id. Pass anti_join_strategy prices both trees by their summed row estimates and lowers the cheaper one; the physical AntiJoin records strategy correlated or key_set, and AntiJoinMaskExec folds a key-set inner tree into one aggregate per outer key as it streams. Saved bound plans move to version 8. GQT gains the plan line `anti join $var: <correlated|key_set>`.
Resolve against the refusal diagnostics (ModernRelay#815) and checked-query read plans (ModernRelay#839): main took bound plan version 8, so the AntiJoin strategy moves to version 9.
5 tasks done
Review: key-set anti-join for bounded correlated blocksRecommendation: approve after the docs conflict with Reviewed head: Why, what, how, impact
Findings
Tradeoffs and liability
ValidationToolchain 1.97.1 (pinned) and protoc 3.21.12, on a local filesystem. All commands ran with
Limitations:
|
…key-set root and its equivalence Plan acceptance refuses an AntiJoin whose inner tree does not have the shape its strategy declares (check `block strategy`). The key-set root choice and its tie rule have planner tests; a differential property test compares every key-set block shape with its forced-correlated twin. The arena swap refuses a key-set tree holding a correlated block, and the execution guide notes that a hub root is under-priced.
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.
Summary
A correlated block such as
ran its inner tree once, but from the outer rows: every outer row's
$xwas expanded to all of that identity's touchpoints, each looked up in
Touchpointand thenSourcein slices of 256 rows. The cost was the outer rowstimes
$x's degree, even though the excluded side is small.The planner now builds a second inner tree for blocks that touch the outer row
only through the correlation binding's identity: the same pattern, rooted at the
block's most bounded binding (a key or unique equality or an
inlist on itsown scan), with each
Expandtoward that root reversed, ending on$x's id.A new physical pass,
anti_join_strategy, prices both trees by their summed rowestimates and lowers the cheaper one. The physical
AntiJoinrecordsstrategy: correlated | key_set. Underkey_set,AntiJoinMaskExecstreamsthe inner rows into one
SubqueryAggregategroup per outer key and gives eachouter row its key's verdict. This is a set-based anti-join on the shared
variable; count and aggregate blocks (
count { } >= 2,sum($t0.w) { } > 3)use the same path.
The logical plan still holds the correlated tree as the block's meaning. A block
stays
correlatedwhen it reads an outer column (including properties of thecorrelation binding), when no binding is bounded, when it nests a block or
starts a second component, or under a traversal work limit.
Measurements
All numbers below are from a read-only copy of a production CRM graph's storage
(synced 2026-10-12, the same table versions the live server plans with:
identical explain output and
assumptions). Nothing was written to the livedeployment, and no maintenance ran on it. On the live 0.13 server, the read took
about 88 s and the same read without the block about 25 s.
Where the 60 s went
The cardinalities for one channel watching 143 sources and one excluded source:
$t,$xpairs)$x)$x → $t0Touchpointid lookups /Sourceid lookups$s0 → $t0)The
id_lookupscans each read the whole table, because the live tables have noindexes (
index_factsare empty, and there is no_indices/under any table),and the pinned table versions have 9 to 35 fragments each.
Object-store requests: correlated vs key set
These numbers come from the same storage served by a local S3 endpoint (
moto),counting the object GETs in its access log per query. Lance
data/*.lancerangereads dominate them. Wall time is through that endpoint: the absolute times
include moto's per-request cost, but the 0.13 times track the live server
closely (21.8 s vs 25 s live for the base read, 74.6 s vs 88 s live with the
block).
not { }(correlated)not { }(key_set)¹ A debug build; its base read takes 30.8 s through the same endpoint, so the
block adds about 0.3 s instead of about 53 s.
²
$x.slug = $x.sluginside the block reads the outer row, so the block stayscorrelated. This reproduces 0.13's request count exactly.
The block's own cost drops from +11,884 to +260 GETs (46×). Results are
byte-identical across 0.13,
key_setand forced correlated.The checked-in instrument (
anti_join_cost.rs, ignored,instrument:) runs thethree reads over a synthetic CRM-shaped graph (48 commits, no indexes) or over
any graph named by
OMNIGRAPH_ANTI_JOIN_STORE, and reports rows through theoperators and wall time. On this copy:
key_setThe 25 s base cost is storage fragmentation, not missing indexes
I measured the base read (no block) on three storage layouts of the same rows:
omnigraph optimize(0.13)Compaction alone removes 93% of the base read's requests. Index builds do not
help this read: every
id_lookupslice adds BTREE probes, so the indexedlayout issues more requests than the compacted, unindexed one. The plan
multiplies the per-scan cost:
Touchpointis reached byid_lookupin 168slices because the planner estimated a frontier of 1,890 rows where there were
42,912 (average fan-out over a skewed channel). A hash join would read the
table once, but the estimate kept it below
HASH_JOIN_RATIO. Requests aretherefore roughly slices × fragments × columns.
Recommended for that deployment (not done here): run
optimizeon the graph,with its writers paused. With this change
deployed, the lookalike read should take about as long as its base read: on
today's layout +260 GETs over the base, and after compaction about 1,800 GETs
in total.
Tests
crates/omnigraph-gqt/cases/v2/planner/anti_join_key_set_from_a_bounded_binding.gqt:the CRM shape at small scale. It pins
anti join $x: key_setandpass anti_join_strategywithexpect same as v1, covers count, sum andbound-edge blocks, and has two counterexamples that stay
correlated(anunbounded inner binding, and a block that reads
$t). The new plan lineanti join $var: <correlated|key_set>is added to the GQT grammar withparser and checker tests. The runner's saved-plan round trip now also
compares the strategy.
engine_v2.rs::bounded_correlated_block_runs_once_from_its_key_set: a400-outer-row graph where the correlated tree would expand 12,030 rows. It
asserts the
KeySetplan, noOuterReference, the anti-join input of 400rows, both inner expands at exactly 3 rows (the excluded touchpoints), and the
kept identities.
query_plan/cost.rs: the key-set tree's shape (a table scan of the boundedbinding, then both hops reversed, ending on
$awith no scan), theanti_join_rows($a)statistic both ways (4001 vs 121 chooseskey_set,122 vs 1201 keeps
correlated), and the ineligible shapes.report/tests.rs: a key-set block's operators all execute and the plan has noouter leaf.
crates/omnigraph-gqt/cases/v2/planner/anti_join_key_set_reverses_hops_and_counts.gqt:the reversal's semantics, each step pinned to
key_set. It coversknows{2,2},knows{1,2}and a block written from the bound binding(
$x knows $p), each withexpect same as v1. Overparallel edges, an unbound
count { } = 1counts distinct neighbours, and abound-edge
count { } = 1/= 2counts edge rows.bound_plan.rs:BOUND_PLAN_VERSIONis 9, every older version is refused,and
strategyround-trips.two new cases,
anti_join_negation_forms_agree'snot_at_acmeandnot_at_acme_bindingnow plankey_setand keep their expected rows. Everyother existing block stays
correlated, because it has no key-bounded innerbinding or reads the outer row.
Run locally:
cargo test -p omnigraph-planner,cargo test -p omnigraph-gqt-core --lib,the full GQT corpus (350 cases; 29 write/DST cases time out at 10 s under
concurrent machine load and pass when rerun serially),
cargo test -p omnigraph-engine --test integrationand the engine--libreport tests,
cargo fmt --all --check, and clippy on the changed crates.Notes and follow-ups
maintook 7 forcount(distinct ...)in feat(gq)!: add
count(distinct ...)#965 and 8 for typed ordering in feat(planner): accept every read plan against its checked query (RFC 0047) #839; all unreleased). An older plan isrefused with the usual regeneration instruction; see
correlated-block-key-set.breaking.md.correlatedstill carries the whole outer rowwidth into its inner tree and expands every outer row, duplicates included.
Deduplicating the correlation keys first would bound that path by distinct
keys too. This PR does not do that.
IoCounts.data_readsin the cost harness misses most table reads on somegraphs (0 on the reloaded layout, about 2,000 where moto saw 15,120 on the
as-is copy). That is why this PR's request counts come from the S3 endpoint
log rather than the in-process probe.
Touchpointaccess path is chosen from a 1,890-row estimateagainst 42,912 actual rows. An
id_lookupthat observes a frontier pastHASH_JOIN_RATIOcould switch to a hash join at run time, asExpandswitches modes; today the declared switch only goes the other way.
order { repos desc } limit 500over an aggregate declares no tie-break, sowhich tied rows make the cut depends on the storage layout.