Skip to content

perf(planner): count same-type two-hop paths that share an end - #131

Merged
prrao87 merged 1 commit into
mainfrom
perf/count-shared-end-hops
Nov 7, 2026
Merged

prrao87 merged 1 commit into
mainfrom
perf/count-shared-end-hops

Conversation

@prrao87

@prrao87 prrao87 commented Nov 7, 2026 •

Copy link
Copy Markdown
Member

Why

The two-hop count rewrite (#97, #113) counts MATCH (a)-[:R]->(b)-[:S]->(c) RETURN count(*) from per-node degrees instead of building every path. Two hops of the same type that share an end, such as (a)-[:FOLLOWS]->(b)<-[:FOLLOWS]-(c) ("pairs of people who follow the same person"), kept the join plan, which builds Σ in(b)² rows only to count them. The reason was the correction: Cypher's rule that a path uses each edge once excludes every pair whose far ends are the same node, and finding those pairs needs both ends of each edge. This is one of the open items in #78.

What changed

  • Same-type hops that share an end are now counted from their edges. Each hop's rows are grouped by (b, far end) and then by b, which gives both sides' counts n1(b), n2(b) and the excluded pairs Σ_x k1(b, x) × k2(b, x) in one pass. The count is Σ_b n1(b) × n2(b) less those pairs.
  • Edges are compared by endpoints, as on the join path, so k parallel edges between b and x exclude k² pairs.
  • These hops don't count in place on the CSR (degrees=), because the correction needs the far end. They read the CSR expansion's rows into a hash aggregate instead.
  • The same conditions as before decide when the rewrite applies: each WHERE condition reads one hop, and the mean fan-outs say there are at least twice as many paths as edges.
  • The CSR guide and the optimizer internals page describe the new case.
MATCH (a:Person)-[:FOLLOWS]->(b:Person)<-[:FOLLOWS]-(c:Person) RETURN count(*)

before: HashJoinExec over two FOLLOWS hops, one row per path, then a count
after:  each hop grouped by (b, far end) through a UNION, then by b; no path is built

Validation

  • cargo test --workspace, cargo clippy --workspace --all-targets -- -D warnings and cargo fmt --all --check pass. make test (1710 passed) and make lint pass, as do zensical build --strict and typos. CI is pending.

  • New tests check the count against the join plan (forced with count(*) + 0, which the rewrite doesn't match), with and without the CSR: hops into and out of the shared node, the shared node as the root of a comma pattern, parallel edges (k² pairs excluded), a self-loop at the shared node, and filters on either hop, on the shared node and on a relationship.

  • graph-benchmark (100K Person, 2.4M FOLLOWS, in memory), median ms of 11 runs after 3 warm-ups, two alternating processes per build; Apple M5, 24 GB, macOS 26.6.2; release wheels built from each commit and loaded with uv run --with. Answers are equal across builds and engines.

    Query Paths main join main CSR branch join branch CSR
    (a)-[:FOLLOWS]->(b)<-[:FOLLOWS]-(c) RETURN count(*) 4,835,011,370 7,957.5 9,402.6 43.2 47.8
    (a)<-[:FOLLOWS]-(b)-[:FOLLOWS]->(c) RETURN count(*) 58,420,136 261.4 162.1 47.3 50.5
    the first, WHERE b.age < 50 AND a.age > 25 3,785,530,229 6,269.2 6,568.1 34.4 39.4
    q8, the chain (control, unchanged) 58,431,994 19.6 12.9 20.4 13.2

    With the CSR these hops are a little slower than without it: they can't count in place, so the CSR expansion's rows go through the same hash aggregate as the join path's scan.

  • Both suites (GDC SF1, graph-benchmark-ldbc 3afadb9 and graph-benchmark-ldbc-complex fc7b44c, both modes): the physical plan of every query, captured through explain() for each wheel, is the same as on main for this branch. None of their queries has this shape, so their timings can't change through it; the suites were timed once for the top of this stack, in perf(planner): count two-hop paths whose second hop is optional #133, every test passing.

Part of #78.

🤖 Generated with Claude Code

Two hops of one type that share an end, such as (a)-[:R]->(b)<-[:R]-(c),
kept the join plan in the two-hop count rewrite because the paths whose
far ends are the same node would use one edge twice. Group both hops by
(b, far end) and then by b, and subtract the pairs with the same far end,
so the count still follows the edges rather than the paths.
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying lance-csr with  Cloudflare Pages  Cloudflare Pages

Latest commit: fa2389f
Status: ✅  Deploy successful!
Preview URL: https://1d3ef55b.lance-csr.pages.dev
Branch Preview URL: https://perf-count-shared-end-hops.lance-csr.pages.dev

View logs

@prrao87
prrao87 marked this pull request as ready for review November 7, 2026 02:29
@prrao87
prrao87 merged commit 381de46 into main Nov 7, 2026
8 checks passed
@prrao87
prrao87 deleted the perf/count-shared-end-hops branch November 7, 2026 02:30
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