Skip to content

perf(csr): expand one-hop optional match as an outer csr hop - #118

Merged
prrao87 merged 1 commit into
mainfrom
perf/optional-match-outer-expand
Nov 3, 2026
Merged

prrao87 merged 1 commit into
mainfrom
perf/optional-match-outer-expand

Conversation

@prrao87

@prrao87 prrao87 commented Nov 3, 2026 •

Copy link
Copy Markdown
Member

Why

OPTIONAL MATCH planned its input twice: once as the left side, and once more as the seed its optional pattern expands from, before joining the two back together with a left join. After #108 this copy was the largest remaining duplication in IC10's plan, as noted in #18. For a single hop, the CSR can produce the same rows from one pass over the input if input rows without a neighbour are kept.

Closes #18. With #108, IC10 has gone from 209 to 42 physical operators and from 97.5 to 20.5 ms in memory mode, with results unchanged and no regression elsewhere, which meets that issue's acceptance criteria. IC1 is not covered, because its OPTIONAL MATCH clauses are two-hop chains; that work, and the remaining gap to other engines, is for a new issue.

What changed

  • CsrExpand gains an outer mode, shown as outer in explain(). It records which input rows produced output, and after each input batch it emits the rows that produced none, with nulls in the columns the hop adds. An edge to a missing target node counts as no match.
  • A one-hop OPTIONAL MATCH from its only shared variable, with no WHERE of its own, now plans as one outer expansion of its input when the hop uses the CSR and fetches the target's columns in place. Every other OPTIONAL MATCH keeps the seed and left join: its own WHERE, more than one hop, pattern predicates, or a hop without a CSR.
  • No filter moves into an outer hop, and an outer hop is never counted in place. Either would change results: a filter moved inside would turn a row it should drop into a row of nulls.
  • The operator internals page lists the new capability.
MATCH (a:Person) OPTIONAL MATCH (a)-[:KNOWS]->(b:Person) RETURN a.id, b.id

before: HashJoinExec join_type=Left over the input and a CsrExpandExec on its distinct ids
after:  CsrExpandExec: key=a__id, edges=..., take=b[b__id*], outer

Validation

  • cargo test --workspace and cargo clippy --workspace --all-targets -- -D warnings pass.
  • Python tests pass (1694). New cases cover repeated input rows and a WHERE after the optional hop, which must still drop the null rows. The plan test checks that a single hop is one outer expansion with no join, and that a WHERE on the optional part keeps the left join.
  • The strict docs build and typos pass.
  • Both benchmark suites, run with the CONTRIBUTING.md harness on SF1 on an Apple M5. The two release wheels were built from main at 9a92889 and from this branch's change on top of it. Each wheel ran twice per mode, alternating, and the table gives the better median in ms. Every query's result matched its expected answer.
IC10 main branch change
memory 24.62 20.53 −16.6%
lazy 33.88 28.84 −14.9%
physical operators 63 42

Planning time for IC10 (explain()) dropped from about 15 ms to 10 ms.

Branch time divided by main time, as a geometric mean over every query:

Suite memory lazy
IC1 to IC14 0.986 0.984
30 custom queries 1.001 1.001
Every query (ms, better median of two runs)
IC memory main memory branch lazy main lazy branch
1 22.90 23.04 32.15 32.04
2 2.46 2.42 24.98 24.90
3 25.48 25.13 159.75 162.68
4 5.75 5.75 12.88 12.80
5 74.96 74.79 117.05 108.56
6 20.87 21.76 23.60 23.12
7 9.82 9.62 14.45 14.46
8 2.27 2.26 3.30 3.30
9 37.13 37.75 297.83 305.04
10 24.62 20.53 33.88 28.84
11 3.01 3.03 5.39 5.39
12 18.43 17.82 20.17 20.06
13 1.09 1.10 1.43 1.43
14 48.80 48.49 50.13 50.21
q memory main memory branch lazy main lazy branch
1 0.62 0.62 1.15 1.14
2 0.54 0.54 1.38 1.38
3 0.65 0.65 0.78 0.79
4 0.51 0.51 0.78 0.79
5 0.77 0.78 2.12 2.13
6 0.43 0.43 0.73 0.73
7 1.03 1.05 1.08 1.09
8 0.63 0.63 1.72 1.71
9 0.81 0.82 1.34 1.33
10 0.72 0.73 0.93 0.92
11 1.54 1.54 2.00 1.98
12 0.76 0.75 6.41 6.72
13 0.80 0.80 1.04 1.03
14 0.53 0.53 0.68 0.68
15 0.56 0.56 0.76 0.75
16 0.67 0.66 1.09 1.08
17 0.91 0.91 1.39 1.39
18 0.70 0.70 1.13 1.13
19 0.93 0.93 1.34 1.35
20 1.94 1.92 16.61 16.51
21 0.77 0.76 0.83 0.83
22 0.71 0.71 0.91 0.92
23 0.73 0.73 1.22 1.22
24 0.69 0.70 1.06 1.06
25 0.79 0.80 1.18 1.19
26 0.74 0.74 1.06 1.06
27 0.86 0.86 1.29 1.30
28 1.41 1.41 2.03 2.04
29 0.69 0.69 1.02 1.01
30 41.84 41.76 42.29 42.61

IC10 is the only query whose physical plan changed; every IC plan was compared between the two wheels. IC3's plan text differs only in the order of two filter conjuncts, which varies from one process to the next on main too. None of the 30 custom queries uses OPTIONAL MATCH. The larger moves elsewhere are therefore noise: IC5 lazy (−7.3%; its two main runs alone were 6% apart), q12 lazy (+4.9%) and IC6 memory (+4.3%, whose runs overlap).

🤖 Generated with Claude Code

A single-hop OPTIONAL MATCH with no WHERE of its own now runs as one
outer CsrExpand over its input, instead of planning the input again as a
seed and left-joining the optional side back. Input rows with no
neighbour keep one row with null hop columns. Filters never move into an
outer hop, and it is never counted in place.

Part of #18.
@prrao87
prrao87 marked this pull request as ready for review November 3, 2026 21:19
@prrao87
prrao87 merged commit 050c39e into main Nov 3, 2026
8 checks passed
@prrao87
prrao87 deleted the perf/optional-match-outer-expand branch November 3, 2026 21:19
prrao87 added a commit that referenced this pull request Nov 3, 2026
Minor bump, chosen by the maintainer: the release adds a user-facing
feature, GRAPH_TABLE patterns on the CSR through SqlEngine (#115),
alongside the one-hop OPTIONAL MATCH speedup (#118). Neither changes the
Python or Rust API incompatibly.
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.

Shrink plans for comprehension-heavy queries such as IC10

1 participant