perf: cap hash-join build scans - #827
Merged
Merged
Conversation
Contributor
Author
|
Query plan before: |
Contributor
Author
|
Query plan after: |
isProbeSideQualified() required the probe side to contain a filter or an
index scan, so a bare pattern such as
match (a)-[b]->(c) return a.id, c.id
could never seed a probe-to-build semi-mask, and the hash join built its
side over the entire node table (3.77M nodes on cit-Patents).
A rel-scan rooted at EXTEND/PACKED_EXTEND is bounded by the edges it walks,
so it is selective enough to seed the semi-mask even without a predicate.
This is result-preserving for inner joins: the semi-mask only prunes
build-side node scans to node IDs the probe provably produces, and the
planner's PROHIBIT_PROBE_TO_BUILD cardinality guard is still respected.
adsharma
force-pushed
the
remote-extend-sip
branch
from
August 21, 2026 17:38
fd9d9be to
e0fc7f1
Compare
A query-final literal LIMIT can cap the probe side that seeds the
probe-to-build semi-mask, bounding both the probe input and, via the mask,
the build-side node scan:
match (a)-[b]->(c) return a.id, c.id limit 10;
on cit-Patents (16.5M edges / 3.77M nodes) drops HASH_JOIN_BUILD from
3,258,983 to 22 tuples; PROFILE wall clock ~15.5s -> ~170ms with identical
results.
The push is applied only where it cannot change results:
- rewrite() walks the plan tail from the root and stops at anything that
reorders or multiplies rows (ORDER_BY, AGGREGATE, FLATTEN, ...). Only
PROJECTION (1:1), MULTIPLICITY_REDUCER (expands rows to their
multiplicity >= 1, never collapses) and the transparent EXPLAIN wrapper
may sit between the LIMIT and the join;
- the LIMIT must be literal, have no SKIP, and target exactly one INNER
hash join - the one directly below that tail;
- isBuildSideUniquePerKey() requires the build side to be a plain node
table scan under projections: it emits each key exactly once and every
probed key is guaranteed present through the semi-mask, so each probe
row matches exactly once. Filters or extends on the build side
disqualify the push;
- the injected LIMIT sits on a MULTIPLICITY_REDUCER, matching the planner
invariant that every LogicalLimit rests on one (TopKOptimizer
visitLimitReplace depends on it). For flattened probe rows the reducer
is an identity pass-through;
- per-plan state (probeLimit/probeLimitTarget) is reset in every rewrite(),
so no limit leaks across statements.
adsharma
force-pushed
the
remote-extend-sip
branch
from
August 21, 2026 19:04
e0fc7f1 to
6df6ea6
Compare
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
For a bare pattern-match with a
LIMIT, the hash join was building over the entire node table even though the probe side of the join only touches a handful of edges' endpoints.Taking the profile of
on the
cit-PatentsCSR dataset (3.77M nodes), the baseline plan showedSCAN_NODE_TABLE[c](build side) emitting 3,774,768 rows, while the probe rel-scan produced only ~22 rows. All of that build-side work was waste — the probe's semi-mask could restrict the build scan to the ~22 distinctcoffsets it actually needs.This PR makes probe-to-build semi-mask SIP fire for extend (rel-scan) probes, makes the IceDisk (CSR/parquet) node scan honor the semi-mask at both the row-group level and the batch level, and reuses the query
LIMITto cap the probe side that seeds the mask.Measured:
cit-csr)xet://datasets/ladybugdb/ldbc-csr/main/cit-Patents-csr/schema.cypherOn the remote path the win is dominated by not fetching the node-table parquet data for row groups no selected node can land in — fewer HTTP round-trips for the ~3.77M-node table.
Changes (4 commits)
1. Enable probe-to-build SIP for extend scans —
src/optimizer/acc_hash_join_optimizer.cppisProbeSideQualified()previously required the probe sub-plan to contain a filter or index scan. An unfiltered extend scan is now qualified too: a rel-scan rooted atEXTEND/PACKED_EXTENDis bounded by the edges it walks, so it is selective enough to seed a probe-to-build semi-mask even with no predicate. (A plain node-table scan probe is also accepted — it too can only produce a subset of the build side.)2. Honor semi-masks in IceDisk node scans
src/storage/table/ice_disk_node_table.cppinitParquetScanForBatch: when a node-scan picks its next row-group, the batch scheduler tells us that row-group's global offset range (metadata->row_groups[].num_rows), and the group is skipped if the semi-mask has no selected offsets in that range. The per-request for those groups is never even registered/initialized — the key remote win.applySemiMaskFilter()is now applied to each scanned batch, andscanInternal()re-tries (the parquet reader has been advanced, so progress is guaranteed) until it finds a batch there is still an unmasked row in, instead of emitting empty chunks.3. Prune Ice CSR extend source scans by degree
src/processor/operator/scan/scan_rel_table.cppScanRelTable's extend source node scans now receive the shared state's semi-mask, and the source-scan state is initialized with it, so the extend's source-node-scan honors the same mask machinery as regular node scans.4. Reuse the query LIMIT to cap the join's probe side
src/optimizer/acc_hash_join_optimizer.{h,cpp},src/processor/operator/scan/scan_rel_table.cppHashJoinSIPOptimizer::rewritewalks the single-child tail of the logical plan, finds a literalLIMIT(no offset) and stashes it.tryProbeToBuildHJSIPcaps the probe root with thatLogicalLimitbefore it is built into the hash join, so the probe semi-mask is fed from a bounded scan once the LIMIT has been satisfied — this replaced the earlier degree-based source-scan masking inScanRelTable(removed in this commit), which the LIMIT cap makes redundant.Resulting plan
EXPLAINnow shows the build side gated by the probe:Testing
PROFILEon the localcit-csrcopy: build-side scan 3,774,768 → 22 rows; results correct (a.id/c.idtuples).match (n) return n.id limit 3are unaffected (~1 ms).Notes for reviewers
src/storage/table/ice_disk_node_table.cppand the extend source-scan path — seeCODEOWNERS(src/storage @benjaminwinger).