Skip to content

perf: cap hash-join build scans - #827

Merged
adsharma merged 3 commits into
mainfrom
remote-extend-sip
Aug 21, 2026
Merged

perf: cap hash-join build scans#827
adsharma merged 3 commits into
mainfrom
remote-extend-sip

Conversation

@adsharma

Copy link
Copy Markdown
Contributor

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

match (a)-[b]->(c) return a.id, c.id limit 10;

on the cit-Patents CSR dataset (3.77M nodes), the baseline plan showed SCAN_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 distinct c offsets 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 LIMIT to cap the probe side that seeds the mask.

Measured:

before after
local (cit-csr) 368 ms 167 ms (~2.2×)
remote xet://datasets/ladybugdb/ldbc-csr/main/cit-Patents-csr/schema.cypher ~30 s ~20 s

On 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.cpp

isProbeSideQualified() 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 at EXTEND/PACKED_EXTEND is 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.cpp

  • Row-group pruning in initParquetScanForBatch: 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.
  • Batch-level filterapplySemiMaskFilter() is now applied to each scanned batch, and scanInternal() 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.
  • Segment the selection-vector mode after the parquet read so mask filtering starts from every freshly read row.

3. Prune Ice CSR extend source scans by degree

src/processor/operator/scan/scan_rel_table.cpp

  • ScanRelTable'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.cpp

  • HashJoinSIPOptimizer::rewrite walks the single-child tail of the logical plan, finds a literal LIMIT (no offset) and stashes it.
  • tryProbeToBuildHJSIP caps the probe root with that LogicalLimit before 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 in ScanRelTable (removed in this commit), which the LIMIT cap makes redundant.

Resulting plan

EXPLAIN now shows the build side gated by the probe:

SCAN_NODE_TABLE[c]  -- behind the probe's semi-masker: 3,774,768 rows -> 22 rows (local)
SEMI_MASKER         -- over SCAN_REL_TABLE probe
LIMIT 10            -- caps the probe side feeding the mask

Testing

  • PROFILE on the local cit-csr copy: build-side scan 3,774,768 → 22 rows; results correct (a.id/c.id tuples).
  • Non-join queries like match (n) return n.id limit 3 are unaffected (~1 ms).
  • Per-commit builds pass; the storage-layer changes are confined to IceDisk node scans and optimizer qualification.

Notes for reviewers

  • Storage parts touch src/storage/table/ice_disk_node_table.cpp and the extend source-scan path — see CODEOWNERS (src/storage @benjaminwinger).
  • The mask-aware row-group pruning applies wherever IceDisk node tables are scanned; combined with the byte-range fetches on Xet data this is what cuts the remote HTTP round-trips.

@adsharma adsharma changed the title perf: cap hash-join build scans via probe-to-build SIP on extend queries perf: cap hash-join build scans Aug 21, 2026
@adsharma

Copy link
Copy Markdown
Contributor Author

Query plan before:

┌─────────────────────────────┐
│┌───────────────────────────┐│
││       Physical Plan       ││
│└───────────────────────────┘│
└─────────────────────────────┘
┌─────────────────────────────┐
│     RESULT_COLLECTOR[8]     │
│   -----------------------   │
│      Expressions: a.id      │
│            c.id             │
│   -----------------------   │
│     NumOutputTuples: 0      │
│   -----------------------   │
│   ExecutionTime: 0.000000   │
│   -----------------------   │
│     TotalTime: 0.000000     │
└──────────────┬──────────────┘
┌──────────────┴──────────────┐
│          LIMIT[7]           │
│   -----------------------   │
│          Limit: 10          │
│   -----------------------   │
│     NumOutputTuples: 0      │
│   -----------------------   │
│   ExecutionTime: 0.000000   │
│   -----------------------   │
│     TotalTime: 0.000000     │
└──────────────┬──────────────┘
┌──────────────┴──────────────┐
│   MULTIPLICITY_REDUCER[6]   │
│   -----------------------   │
│   -----------------------   │
│     NumOutputTuples: 0      │
│   -----------------------   │
│   ExecutionTime: 0.000000   │
│   -----------------------   │
│     TotalTime: 0.000000     │
└──────────────┬──────────────┘
┌──────────────┴──────────────┐
│        PROJECTION[5]        │
│   -----------------------   │
│      Expressions: a.id      │
│            c.id             │
│   -----------------------   │
│     NumOutputTuples: 0      │
│   -----------------------   │
│   ExecutionTime: 0.000000   │
│   -----------------------   │
│     TotalTime: 0.000000     │
└──────────────┬──────────────┘
┌──────────────┴──────────────┐
│     HASH_JOIN_PROBE[4]      │
│   -----------------------   │
│         Keys: c._ID         │
│   -----------------------   │
│     NumOutputTuples: 0      │────────────────┐
│   -----------------------   │                │
│   ExecutionTime: 0.000000   │                │
│   -----------------------   │                │
│     TotalTime: 0.000000     │                │
└──────────────┬──────────────┘                │
┌──────────────┴──────────────┐ ┌──────────────┴──────────────┐
│      SCAN_REL_TABLE[1]      │ │     HASH_JOIN_BUILD[3]      │
│   -----------------------   │ │   -----------------------   │
│   Tables: cit_Patents_rel   │ │         Keys: c._ID         │
│          Alias: b           │ │        Payloads: c.id       │
│   Direction: (a)-[b]->(c)   │ │   -----------------------   │
│   -----------------------   │ │     NumOutputTuples: 0      │
│     NumOutputTuples: 0      │ │   -----------------------   │
│   -----------------------   │ │   ExecutionTime: 0.000000   │
│   ExecutionTime: 0.000000   │ │   -----------------------   │
│   -----------------------   │ │     TotalTime: 0.000000     │
│     TotalTime: 0.000000     │ │                             │
└─────────────────────────────┘ └──────────────┬──────────────┘
                                ┌──────────────┴──────────────┐
                                │     SCAN_NODE_TABLE[2]      │
                                │   -----------------------   │
                                │     Tables: cit_Patents     │
                                │          Alias: c           │
                                │      Properties: c.id       │
                                │   -----------------------   │
                                │     NumOutputTuples: 0      │
                                │   -----------------------   │
                                │   ExecutionTime: 0.000000   │
                                │   -----------------------   │
                                │     TotalTime: 0.000000     │
                                └─────────────────────────────┘

@adsharma

adsharma commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

Query plan after:

echo "profile match (a)-[b]->(c) return a.id, c.id limit 10;" | ./build/release/tools/shell/lbug -i  cit-csr/schema.cypher
-- Processing: cit-csr/schema.cypher
Opening the database under in-memory mode.
Enter ":help" for usage hints.
┌───────────────────────────────────┐
│┌─────────────────────────────────┐│
││          Physical Plan          ││
│└─────────────────────────────────┘│
└───────────────────────────────────┘
┌───────────────────────────────────┐
│            PROFILE[14]            │
│   -----------------------------   │
│   -----------------------------   │
│        NumOutputTuples: 0         │
│   -----------------------------   │
│      ExecutionTime: 0.000000      │
│   -----------------------------   │
│        TotalTime: 0.000000        │
│   -----------------------------   │
│     WallClockTime: 176.700000     │
└─────────────────┬─────────────────┘
┌─────────────────┴─────────────────┐
│       RESULT_COLLECTOR[13]        │
│   -----------------------------   │
│         Expressions: a.id         │
│               c.id                │
│   -----------------------------   │
│        NumOutputTuples: 10        │
│   -----------------------------   │
│      ExecutionTime: 0.114000      │
│   -----------------------------   │
│        TotalTime: 0.331000        │
└─────────────────┬─────────────────┘
┌─────────────────┴─────────────────┐
│             LIMIT[12]             │
│   -----------------------------   │
│             Limit: 10             │
│   -----------------------------   │
│        NumOutputTuples: 10        │
│   -----------------------------   │
│      ExecutionTime: 0.035000      │
│   -----------------------------   │
│        TotalTime: 0.217000        │
└─────────────────┬─────────────────┘
┌─────────────────┴─────────────────┐
│     MULTIPLICITY_REDUCER[11]      │
│   -----------------------------   │
│   -----------------------------   │
│        NumOutputTuples: 0         │
│   -----------------------------   │
│      ExecutionTime: 0.035000      │
│   -----------------------------   │
│        TotalTime: 0.182000        │
└─────────────────┬─────────────────┘
┌─────────────────┴─────────────────┐
│          PROJECTION[10]           │
│   -----------------------------   │
│         Expressions: a.id         │
│               c.id                │
│   -----------------------------   │
│        NumOutputTuples: 10        │
│   -----------------------------   │
│      ExecutionTime: 0.033000      │
│   -----------------------------   │
│        TotalTime: 0.147000        │
└─────────────────┬─────────────────┘
┌─────────────────┴─────────────────┐
│        HASH_JOIN_PROBE[9]         │
│   -----------------------------   │
│            Keys: c._ID            │
│   -----------------------------   │
│        NumOutputTuples: 10        │───────────────────┬─────────────────────────────────────┐
│   -----------------------------   │                   │                                     │
│      ExecutionTime: 0.095000      │                   │                                     │
│   -----------------------------   │                   │                                     │
│        TotalTime: 0.114000        │                   │                                     │
└─────────────────┬─────────────────┘                   │                                     │
┌─────────────────┴─────────────────┐ ┌─────────────────┴─────────────────┐ ┌─────────────────┴─────────────────┐
│      TABLE_FUNCTION_CALL[7]       │ │        HASH_JOIN_BUILD[8]         │ │        RESULT_COLLECTOR[6]        │
│   -----------------------------   │ │   -----------------------------   │ │   -----------------------------   │
│       Function: READ_FTABLE       │ │            Keys: c._ID            │ │        Expressions: a._ID         │
│         Expressions: a._ID        │ │           Payloads: c.id          │ │               a.id                │
│               a.id                │ │   -----------------------------   │ │               c._ID               │
│               c._ID               │ │        NumOutputTuples: 22        │ │   -----------------------------   │
│   -----------------------------   │ │   -----------------------------   │ │        NumOutputTuples: 10        │
│        NumOutputTuples: 3         │ │      ExecutionTime: 1.874000      │ │   -----------------------------   │
│   -----------------------------   │ │   -----------------------------   │ │      ExecutionTime: 0.157000      │
│      ExecutionTime: 0.019000      │ │       TotalTime: 32.388000        │ │   -----------------------------   │
│   -----------------------------   │ │                                   │ │       TotalTime: 98.365000        │
│        TotalTime: 0.019000        │ │                                   │ │                                   │
└───────────────────────────────────┘ └─────────────────┬─────────────────┘ └─────────────────┬─────────────────┘
                                      ┌─────────────────┴─────────────────┐ ┌─────────────────┴─────────────────┐
                                      │        SCAN_NODE_TABLE[0]         │ │             LIMIT[5]              │
                                      │   -----------------------------   │ │   -----------------------------   │
                                      │        Tables: cit_Patents        │ │             Limit: 10             │
                                      │             Alias: c              │ │   -----------------------------   │
                                      │         Properties: c.id          │ │        NumOutputTuples: 10        │
                                      │   -----------------------------   │ │   -----------------------------   │
                                      │        NumOutputTuples: 22        │ │      ExecutionTime: 0.012000      │
                                      │   -----------------------------   │ │   -----------------------------   │
                                      │     ExecutionTime: 30.514000      │ │       TotalTime: 98.208000        │
                                      │   -----------------------------   │ │                                   │
                                      │       TotalTime: 30.514000        │ │                                   │
                                      └───────────────────────────────────┘ └─────────────────┬─────────────────┘
                                                                            ┌─────────────────┴─────────────────┐
                                                                            │      MULTIPLICITY_REDUCER[4]      │
                                                                            │   -----------------------------   │
                                                                            │   -----------------------------   │
                                                                            │        NumOutputTuples: 0         │
                                                                            │   -----------------------------   │
                                                                            │      ExecutionTime: 0.011000      │
                                                                            │   -----------------------------   │
                                                                            │       TotalTime: 98.196000        │
                                                                            └─────────────────┬─────────────────┘
                                                                            ┌─────────────────┴─────────────────┐
                                                                            │          SEMI_MASKER[3]           │
                                                                            │   -----------------------------   │
                                                                            │   Operators: SCAN_NODE_TABLE[0]   │
                                                                            │   -----------------------------   │
                                                                            │        NumOutputTuples: 22        │
                                                                            │   -----------------------------   │
                                                                            │      ExecutionTime: 0.479000      │
                                                                            │   -----------------------------   │
                                                                            │       TotalTime: 98.185000        │
                                                                            └─────────────────┬─────────────────┘
                                                                            ┌─────────────────┴─────────────────┐
                                                                            │         SCAN_REL_TABLE[2]         │
                                                                            │   -----------------------------   │
                                                                            │      Tables: cit_Patents_rel      │
                                                                            │             Alias: b              │
                                                                            │      Direction: (a)-[b]->(c)      │
                                                                            │   -----------------------------   │
                                                                            │        NumOutputTuples: 22        │
                                                                            │   -----------------------------   │
                                                                            │     ExecutionTime: 97.706000      │
                                                                            │   -----------------------------   │
                                                                            │       TotalTime: 97.706000        │
                                                                            └───────────────────────────────────┘

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.
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
adsharma merged commit 99c7cab into main Aug 21, 2026
4 checks passed
@adsharma
adsharma deleted the remote-extend-sip branch August 21, 2026 20:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant