Skip to content

perf(planner): count two-hop paths from per-node degrees - #97

Merged
prrao87 merged 1 commit into
mainfrom
perf/two-hop-count-from-degrees
Oct 31, 2026
Merged

prrao87 merged 1 commit into
mainfrom
perf/two-hop-count-from-degrees

Conversation

@prrao87

@prrao87 prrao87 commented Oct 31, 2026

Copy link
Copy Markdown
Member

Why

Queries that only count two-hop paths build every path and then throw it away. On graph-benchmark (100K Person, 2.4M FOLLOWS), q8 and q9 build 58M and 45M paths, which makes lance-csr 10 to 20 times slower than Kuzu on q8 while it is the fastest engine on q3 to q7. The number of paths through a middle node b is in(b) × out(b), so the count can come from per-node degrees with work that follows the edges, not the paths. Closes #96.

What changed

MATCH (a)-[r1]->(b)-[r2]->(c) [WHERE ...] RETURN count(*) is now planned as eager aggregation: each hop is planned as a one-hop pattern of its own (CSR expansion included when use_csr=True), grouped to one row per b, and the two sides are joined and summed:

count = Σ_b  n1(b) · n2(b) - s1(b) · s2(b)
  • When it applies: a single count(*), or count(v) of a pattern variable, with no grouping or DISTINCT, over two directed fixed-length hops. Every WHERE conjunct must read the variables of only one hop, so it can filter that hop's edges before grouping. The planner's choice of starting node (q9 starts from b), written-backwards chains and hops of different types are all handled.
  • Cost check: it also needs the engine's mean fan-out at b to say the paths outnumber the edges grouped at least twice (d1·d2 ≥ 2·(d1 + d2)). Without it, graph-benchmark's q5 (one city per person, so no more paths than edges) went from 1.6 to 2.5 ms. Without statistics, the join plan is kept.
  • Relationship uniqueness: when both hops have the same type, the logical planner's existing filter compares edge endpoints, not edge identity. For a chain that only removes pairs of self-loops at b, so the rewrite subtracts s1(b) · s2(b), the self-loops at b that pass each side's filters. The counts match the join plan exactly, including k² excluded pairs for k parallel self-loops, which is what the endpoint shortcut does today (true openCypher would exclude k).
  • Kept on the join plan: same-type hops that share an end, like (a)-->(b)<--(c), whose correction would need grouping by both ends (marked with a shortcut: comment), plus undirected and variable-length hops, predicates across hops such as a.age < c.age, OPTIONAL MATCH, closing edges and anything else.
  • User impact: none beyond speed. Results and output schemas are unchanged. explain shows the new plan for queries it applies to. No API or docs changes.

Validation

  • Rust tests: exact counts for one self-loop, parallel self-loops, parallel ordinary edges, self-loops a filter excludes, q9's filters, written-backwards and mixed-type chains, and count(b). Each count is also checked against the join plan (forced with count(*) + 0) with use_csr off and on. Negative cases check that the shapes above, and a hop with one edge per node, keep the join plan.
  • Python test: five random graphs with self-loops, parallel edges, null ids and endpoints, dangling edges and nulls in filtered columns, eight query shapes, use_csr off and on. Counts and output schemas equal the join plan's.
  • cargo test --workspace, cargo clippy --workspace --all-targets -- -D warnings, cargo fmt --all, make test (1657 passed) and make lint all pass locally. CI is pending.

Benchmarks

Apple M5 (10 cores), 24 GB, macOS 26.6.2, other apps closed. Release wheels built from main at 7774795 (same code as 0.5.0) and from this branch, loaded with uv run --with, main and branch alternated twice per mode, each run started below a 1-minute load of 3. Benchmark repositories: graph-benchmark 0faec9f, graph-benchmark-ldbc 5f528da, graph-benchmark-ldbc-complex f3e63fd. Means of the two runs per build; "run gap" is the difference between the two runs of the same build.

graph-benchmark (memory), where the rewrite applies to q8 and q9:

Query main (ms) branch (ms) Change main run gap
q1 12.69 13.40 +5.5% 1.7%
q2 30.78 31.37 +1.9% 0.9%
q3 2.56 2.60 +1.5% 2.6%
q4 2.12 2.08 -1.8% 6.1%
q5 1.70 1.70 +0.1% 0.8%
q6 2.22 2.21 -0.4% 4.1%
q7 3.20 3.14 -2.0% 7.8%
q8 120.90 21.07 -82.6% 0.1%
q9 95.65 20.69 -78.4% 6.6%

Geomean branch/main 0.698; median main run gap 2.6%.

LDBC suites. No query in either suite qualifies: graph-benchmark-ldbc's q16 is a two-hop count, but each person is located in one place, so the cost check keeps its join plan. Every result check passed, and every change is within run-to-run noise:

Suite Mode Geomean branch/main Median main run gap
graph-benchmark-ldbc memory 1.014 3.2%
graph-benchmark-ldbc lazy 0.995 1.3%
graph-benchmark-ldbc-complex memory 1.005 0.7%
graph-benchmark-ldbc-complex lazy 1.001 0.6%
Every query, both LDBC suites and modes

graph-benchmark-ldbc: memory

Query main (ms) branch (ms) Change main run gap branch run gap
query1 0.62 0.62 -0.2% 1.6% 2.8%
query2 0.52 0.53 +2.0% 5.1% 4.5%
query3 0.64 0.64 +0.5% 3.0% 6.9%
query4 0.49 0.50 +1.8% 5.5% 5.0%
query5 0.77 0.77 -0.0% 1.6% 2.8%
query6 0.42 0.43 +1.8% 3.6% 2.6%
query7 1.02 1.03 +1.0% 3.3% 3.3%
query8 0.63 0.63 +0.0% 3.5% 4.8%
query9 0.81 0.82 +1.5% 3.2% 3.2%
query10 0.72 0.72 +0.2% 3.0% 3.7%
query11 1.54 1.60 +4.5% 3.0% 8.1%
query12 0.75 0.76 +1.0% 0.7% 0.7%
query13 0.79 0.82 +3.5% 4.9% 0.5%
query14 0.53 0.54 +2.5% 3.3% 1.6%
query15 0.56 0.57 +2.0% 3.8% 1.1%
query16 0.65 0.67 +2.2% 3.7% 1.2%
query17 0.90 0.92 +2.0% 4.3% 0.1%
query18 0.69 0.71 +2.3% 3.2% 0.8%
query19 0.93 0.94 +1.1% 2.0% 0.8%
query20 2.00 1.97 -1.4% 0.8% 0.0%
query21 0.78 0.77 -1.0% 2.6% 0.8%
query22 0.71 0.72 +2.0% 2.8% 0.3%
query23 0.73 0.75 +1.8% 3.2% 0.5%
query24 0.69 0.70 +1.3% 3.2% 0.2%
query25 0.79 0.81 +2.1% 2.8% 0.3%
query26 0.74 0.75 +1.9% 3.2% 0.3%
query27 0.85 0.87 +2.4% 2.1% 0.5%
query28 1.37 1.41 +2.9% 2.6% 0.1%
query29 0.68 0.70 +2.1% 3.6% 0.4%
query30 40.37 40.26 -0.3% 1.4% 0.3%

Geomean branch/main 1.014; median main run gap 3.2%

graph-benchmark-ldbc: lazy

Query main (ms) branch (ms) Change main run gap branch run gap
query1 1.16 1.13 -2.0% 2.9% 1.1%
query2 1.39 1.38 -0.3% 1.0% 0.1%
query3 0.80 0.79 -1.1% 2.3% 0.6%
query4 0.79 0.78 -1.1% 1.8% 1.4%
query5 2.17 2.16 -0.7% 1.5% 0.5%
query6 0.73 0.72 -1.4% 2.5% 1.1%
query7 1.09 1.08 -1.0% 2.0% 1.7%
query8 1.76 1.73 -1.7% 1.9% 0.7%
query9 1.35 1.34 -0.7% 1.9% 0.8%
query10 0.94 0.94 +0.3% 0.8% 2.5%
query11 2.00 1.98 -1.3% 1.3% 1.2%
query12 6.56 6.70 +2.1% 0.7% 0.7%
query13 1.05 1.04 -0.7% 1.1% 0.2%
query14 0.69 0.68 -0.6% 1.4% 0.8%
query15 0.76 0.75 -0.8% 0.0% 1.0%
query16 1.08 1.09 +0.2% 0.6% 1.0%
query17 1.40 1.39 -0.7% 0.8% 0.8%
query18 1.14 1.13 -0.7% 0.6% 0.2%
query19 1.36 1.34 -1.7% 1.4% 1.3%
query20 16.39 16.58 +1.2% 0.4% 0.1%
query21 0.84 0.83 -1.0% 1.6% 1.0%
query22 0.93 0.92 -0.4% 1.3% 0.2%
query23 1.24 1.23 -0.7% 1.3% 0.3%
query24 1.06 1.06 -0.0% 1.5% 0.0%
query25 1.19 1.19 +0.4% 0.7% 0.2%
query26 1.07 1.07 -0.7% 0.9% 0.1%
query27 1.30 1.29 -0.4% 1.2% 1.3%
query28 2.05 2.10 +2.6% 1.3% 3.6%
query29 1.03 1.03 -0.1% 0.3% 0.1%
query30 41.95 40.88 -2.6% 3.3% 0.9%

Geomean branch/main 0.995; median main run gap 1.3%

graph-benchmark-ldbc-complex: memory

Query main (ms) branch (ms) Change main run gap branch run gap
query1 25.20 25.40 +0.8% 2.2% 1.4%
query2 2.48 2.53 +1.9% 1.0% 2.1%
query3 55.48 55.75 +0.5% 0.5% 2.2%
query4 5.97 5.96 -0.2% 0.3% 1.4%
query5 94.85 93.90 -1.0% 2.9% 3.2%
query6 22.53 22.29 -1.0% 1.7% 2.0%
query7 16.34 16.49 +1.0% 0.4% 0.6%
query8 2.25 2.27 +1.1% 0.7% 0.3%
query9 38.24 39.13 +2.3% 0.5% 2.1%
query10 98.95 98.80 -0.2% 0.3% 0.3%
query11 3.05 3.08 +0.9% 0.5% 0.3%
query12 18.56 18.56 -0.0% 4.3% 7.4%
query13 1.11 1.12 +1.1% 0.3% 0.3%
query14 50.23 49.90 -0.6% 1.5% 0.2%

Geomean branch/main 1.005; median main run gap 0.7%

graph-benchmark-ldbc-complex: lazy

Query main (ms) branch (ms) Change main run gap branch run gap
query1 32.08 32.05 -0.1% 1.5% 0.5%
query2 25.24 25.13 -0.4% 2.5% 5.1%
query3 165.62 163.78 -1.1% 2.1% 1.9%
query4 12.97 12.85 -0.9% 0.3% 0.5%
query5 109.17 110.90 +1.6% 1.4% 1.2%
query6 23.05 22.69 -1.6% 0.0% 4.2%
query7 23.25 23.26 +0.0% 0.6% 2.1%
query8 3.31 3.28 -0.9% 0.3% 0.9%
query9 304.37 306.86 +0.8% 0.2% 2.3%
query10 130.14 129.58 -0.4% 0.6% 1.2%
query11 5.41 5.44 +0.5% 0.3% 0.4%
query12 21.56 22.45 +4.1% 7.9% 3.7%
query13 1.47 1.44 -1.7% 0.2% 0.3%
query14 50.31 50.85 +1.1% 0.5% 0.5%

Geomean branch/main 1.001; median main run gap 0.6%

🤖 Generated with Claude Code

`MATCH (a)-[r1]->(b)-[r2]->(c) [WHERE ...] RETURN count(*)` built every
path before counting it. The paths through each middle node b number
in(b) × out(b), so plan each hop on its own, group it down to one row
per b, join the two and sum the products (eager aggregation). The work
follows the edges, not the paths.

It applies when every WHERE conjunct reads only one hop's variables, and
when the engine's mean fan-out at b says the paths outnumber the edges
grouped there at least twice; with one edge per node on a side (q5 of
graph-benchmark, one city per person) grouping only adds work. When both
hops share a type, the planner's uniqueness filter compares endpoints, so
the rewrite subtracts s1(b) × s2(b) self-loop pairs, matching the join
plan's counts exactly, k² for k parallel self-loops. Same-type hops that
share an end keep the join plan.

graph-benchmark (100K Person, 2.4M FOLLOWS), release builds, M5:
q8 120 -> 20 ms, q9 92 -> 19 ms, other queries unchanged. Both LDBC
suites, memory and lazy: geomean branch/main 1.014, 0.995, 1.005,
1.001, all within noise.

Closes #96.
@prrao87
prrao87 marked this pull request as ready for review October 31, 2026 01:36
@prrao87
prrao87 merged commit a85f5f7 into main Oct 31, 2026
7 checks passed
@prrao87
prrao87 deleted the perf/two-hop-count-from-degrees branch October 31, 2026 01:48
prrao87 added a commit that referenced this pull request Oct 31, 2026
A patch release: nothing since 0.5.0 breaks the Python or Rust API, so
RELEASE.md's rule gives a patch bump. It ships the two-hop count rewrite
(#97), `optimize_csr()` (#98), per-chunk in-memory relationship filters
(#100) and the node lookup after grouping by id (#101).

Also refresh the version in the three lockfiles, which bump-my-version
does not update.
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.

Count two-hop paths from per-node degrees instead of enumerating them

1 participant