Repository navigation
perf(planner): count two-hop paths from per-node degrees - #97
Merged
Merged
Conversation
`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
marked this pull request as ready for review
October 31, 2026 01:36
This was referenced Oct 31, 2026
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.
This was referenced Oct 31, 2026
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.
Why
Queries that only count two-hop paths build every path and then throw it away. On graph-benchmark (100K
Person, 2.4MFOLLOWS), 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 nodebisin(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 whenuse_csr=True), grouped to one row perb, and the two sides are joined and summed:count(*), orcount(v)of a pattern variable, with no grouping orDISTINCT, over two directed fixed-length hops. EveryWHEREconjunct 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 fromb), written-backwards chains and hops of different types are all handled.bto 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.b, so the rewrite subtractss1(b) · s2(b), the self-loops atbthat 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).(a)-->(b)<--(c), whose correction would need grouping by both ends (marked with ashortcut:comment), plus undirected and variable-length hops, predicates across hops such asa.age < c.age,OPTIONAL MATCH, closing edges and anything else.explainshows the new plan for queries it applies to. No API or docs changes.Validation
count(b). Each count is also checked against the join plan (forced withcount(*) + 0) withuse_csroff and on. Negative cases check that the shapes above, and a hop with one edge per node, keep the join plan.use_csroff 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) andmake lintall pass locally. CI is pending.Benchmarks
Apple M5 (10 cores), 24 GB, macOS 26.6.2, other apps closed. Release wheels built from
mainat 7774795 (same code as 0.5.0) and from this branch, loaded withuv run --with,mainand 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:
main(ms)mainrun gapGeomean branch/main 0.698; median
mainrun 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:
mainrun gapEvery query, both LDBC suites and modes
graph-benchmark-ldbc: memory
main(ms)mainrun gapGeomean branch/main 1.014; median main run gap 3.2%
graph-benchmark-ldbc: lazy
main(ms)mainrun gapGeomean branch/main 0.995; median main run gap 1.3%
graph-benchmark-ldbc-complex: memory
main(ms)mainrun gapGeomean branch/main 1.005; median main run gap 0.7%
graph-benchmark-ldbc-complex: lazy
main(ms)mainrun gapGeomean branch/main 1.001; median main run gap 0.6%
🤖 Generated with Claude Code