Skip to content

perf(csr): match the join path on whole-relationship queries - #113

Merged
prrao87 merged 7 commits into
mainfrom
perf/csr-full-relationship-plans
Nov 3, 2026
Merged

prrao87 merged 7 commits into
mainfrom
perf/csr-full-relationship-plans

Conversation

@prrao87

@prrao87 prrao87 commented Nov 3, 2026 •

Copy link
Copy Markdown
Member

Why

#112 showed use_csr=True losing to the join path on every graph-benchmark query that reads the whole FOLLOWS relationship (q1, q2, q8, q9), mostly because of plan choices rather than the CSR itself. The goal was for the CSR to be at least even on those queries, so the benchmark and users can keep it on. Each finding in the issue is its own commit here.

What changed

  • Unread relationships stay unbound in the two-hop count (finding 1). The rewrite no longer copies r1/r2 into the hops it plans unless a hop's own filter reads them, so the CSR doesn't fetch edge rows nothing uses.
  • Late-lookup columns aren't fetched by the hop below (finding 4). PruneCsrTargetColumns collected used columns by name over the whole plan, so b__name fetched by a NodeLookup above an aggregate kept the hop under it taking b__name for every edge. Each lookup's input is now pruned against what that input outputs. It does this only when a hop below fetches a same-named column, and in a single pass: a first version re-walked nested lookups and added about 3 ms of planning to IC1, IC10 and IC14.
  • Grouping by a node's id and its other properties groups by the id (finding 3). RETURN b.id, b.name, count(*) now groups by b__id and fetches b.name after the aggregate with NodeLookup, as WITH b already did. It applies under the same conditions as before (in-memory node index). Grouping by properties without the id keeps today's plan.
  • Two-hop counts come from per-node degrees (finding 2). Both hops are re-rooted at the middle node, with filters on it applied before expanding. On the CSR each hop counts each node's neighbours in place (degrees=[__n1, __s1] in explain()): one row per node instead of one per edge. It also counts self-loops for the correction, and a far-end filter such as c.age > $age_2 is still checked per neighbour. Neighbours without a node row are not counted, as on the join path. The counting is chosen in a new optimizer rule, CountCsrDegrees, through a HopDegreesNode, because the far-end filter can hold query parameters that are bound into the cached plan template after planning. A hop that doesn't qualify keeps the hash aggregate.
  • The first hop is counted per node before the rest of the pattern (finding 5). Profiling q2 showed the cost wasn't the string gathers as such. The CSR expansion's 2.4M rows reach the aggregate spread over every partition, so each partial aggregate built a table of nearly every group, here keyed on four strings (the join path repartitions by person before its join, so its partials are disjoint). Grouping by person ids first only got q2 to 35 ms. Instead, when every aggregate is a plain count and nothing else reads the root variable x or its relationship, MATCH (x)-[:R]->(m)... RETURN ..., count(x) counts each m's x neighbours in place, plans the rest from m, joins the counts on m's id and sums them. This applies only with the CSR, at a mean fan-out of at least two. It also takes q1 well below the join path.
  • Docs. The CSR guide has a new "When it helps" section: whole-relationship hops, what degrees= means, and that edges= is the relationship's edge count rather than what the hop reads. The internals pages cover the two counting rewrites, the new rule and node, and the pruning change. The guide and the home page now also say that the join fallback is chosen by what the CSR can express, not by cost, and that use_csr applies to the whole engine.

Validation

  • cargo test --workspace, cargo clippy --workspace --all-targets -- -D warnings and cargo fmt --all --check pass, and each commit was checked on its own (fmt, clippy, the lance-csr unit tests and the strict docs build). make test (1678 passed) and make lint pass. zensical build --strict, typos and check_snippets.py pass. CI is pending.

  • New tests cover the two-hop count with named and unnamed relationships, with and without self-loops, dangling edges, filters on each end and on the middle node, and a parameterized far-end filter run twice through the plan cache. They also cover the first-hop count (grouped, filtered, empty, and shapes that must keep the usual plan), and grouping by n.id plus other properties, including duplicates and OPTIONAL MATCH, against grouping by properties alone.

  • graph-benchmark (100K Person, 2.4M FOLLOWS, in memory), using the issue's reproduction script: median of 25 runs after 5 warm-ups, two alternating processes per build, every answer checked equal between the engines. The pinned runner builds its engine without use_csr, and runner settings shouldn't be edited, so this used the script rather than its pytest harness. Queries are those of 7ca5d17.

    Query Join path use_csr=True on main use_csr=True on this branch
    q1 12.5 28.8 7.6
    q2 30.6 91.8 12.2
    q8 19.5 29.4 13.3
    q9 18.1 23.5 12.8

    q3 to q7 are unchanged (each within 0.1 ms).

  • CONTRIBUTING suites (GDC SF1, release wheels from main e2339cc and this branch, uv run --with, both modes, alternating main/branch twice, settings as in CONTRIBUTING.md). Every test passes. Geometric means of branch/main: 1.010 (custom, memory), 1.014 (custom, lazy), 1.014 (IC, memory), 1.012 (IC, lazy). "Plan changed" compares each query's physical plan between the two wheels. Every IC plan change is finding 4 (fewer columns fetched) or finding 3 (IC12 groups by friend.ID and looks up the names). The two rows outside 5%:

    • IC5: −8% in memory, where the plan stopped fetching forum.title and forum.creationDate per edge. +5% in lazy with an identical plan.
    • IC12: +12% memory, +10% lazy. This query swings between about 17 and 20 ms per process on identical builds: main alone measured 17.3 and 20.0 ms in two processes. On this branch, the old grouping (forced with friend.ID + 0) is no faster than the new one within the same process (20.5 vs 20.0 ms, 20.3 vs 19.6 ms). I read it as process noise, not the plan change.
Every query, both suites and modes (ms, mean of two runs each)

graph-benchmark-ldbc, memory

Query main (ms) branch (ms) change main run gap plan changed
q1 0.62 0.62 +0.4% 3.7%
q2 0.53 0.53 +0.1% 5.0%
q3 0.64 0.63 -1.7% 3.5%
q4 0.50 0.50 -0.5% 3.8%
q5 0.76 0.76 +0.3% 0.4%
q6 0.42 0.43 +1.3% 0.1%
q7 1.01 1.04 +2.7% 0.2%
q8 0.62 0.62 +0.9% 0.2%
q9 0.80 0.82 +2.4% 0.1%
q10 0.71 0.72 +0.9% 1.9%
q11 1.51 1.52 +0.7% 0.7%
q12 0.75 0.76 +1.4% 1.2%
q13 0.80 0.81 +0.1% 2.9%
q14 0.53 0.53 -1.6% 2.9%
q15 0.56 0.56 -1.5% 2.1%
q16 0.65 0.67 +1.9% 0.5%
q17 0.89 0.90 +1.6% 0.2%
q18 0.69 0.71 +2.3% 0.2%
q19 0.91 0.93 +2.5% 1.4%
q20 2.07 2.06 -0.5% 1.1%
q21 0.73 0.74 +1.0% 2.9%
q22 0.69 0.71 +2.9% 0.8%
q23 0.73 0.73 +0.1% 3.6%
q24 0.68 0.69 +2.1% 1.0%
q25 0.78 0.80 +2.0% 0.6%
q26 0.73 0.73 +0.6% 0.4%
q27 0.85 0.87 +2.0% 0.4%
q28 1.39 1.42 +2.2% 0.1%
q29 0.67 0.68 +1.3% 0.0%
q30 42.12 43.16 +2.5% 4.0%

Geometric mean of branch/main: 1.010; median main run gap 1.0%, max 5.0%

graph-benchmark-ldbc, lazy

Query main (ms) branch (ms) change main run gap plan changed
q1 1.13 1.16 +2.4% 1.8%
q2 1.39 1.38 -0.3% 3.8%
q3 0.78 0.79 +0.9% 5.1%
q4 0.77 0.78 +1.0% 3.8%
q5 2.13 2.13 +0.0% 1.6%
q6 0.71 0.71 +0.6% 4.5%
q7 1.06 1.08 +2.2% 2.8%
q8 1.70 1.71 +0.8% 1.4%
q9 1.34 1.34 -0.2% 1.4%
q10 0.92 0.93 +0.6% 1.3%
q11 1.95 1.97 +1.1% 3.3%
q12 6.83 6.62 -3.1% 4.1%
q13 1.03 1.06 +2.0% 2.2%
q14 0.70 0.68 -2.1% 9.3%
q15 0.74 0.76 +2.0% 2.7%
q16 1.08 1.12 +3.9% 0.2%
q17 1.37 1.39 +1.2% 2.7%
q18 1.12 1.14 +1.8% 3.0%
q19 1.33 1.35 +2.0% 3.0%
q20 16.52 16.60 +0.5% 3.0%
q21 0.83 0.85 +2.7% 0.8%
q22 0.92 0.94 +3.0% 1.0%
q23 1.25 1.25 +0.1% 1.3%
q24 1.06 1.08 +1.6% 2.0%
q25 1.18 1.23 +4.2% 0.3%
q26 1.07 1.09 +1.6% 1.1%
q27 1.29 1.32 +2.2% 0.4%
q28 2.03 2.08 +2.6% 1.3%
q29 1.02 1.05 +3.0% 1.1%
q30 42.26 43.81 +3.7% 0.9%

Geometric mean of branch/main: 1.014; median main run gap 2.0%, max 9.3%

graph-benchmark-ldbc-complex, memory

Query main (ms) branch (ms) change main run gap plan changed
IC1 23.02 23.13 +0.5% 0.7% yes
IC2 2.46 2.50 +1.6% 1.2%
IC3 25.67 25.55 -0.4% 0.5% yes
IC4 5.82 5.86 +0.7% 0.5%
IC5 80.24 73.65 -8.2% 3.5% yes
IC6 22.24 22.95 +3.2% 0.4%
IC7 9.80 9.80 -0.0% 0.1% yes
IC8 2.24 2.30 +2.6% 0.4%
IC9 38.55 38.81 +0.7% 0.9%
IC10 24.46 24.58 +0.5% 1.1%
IC11 2.98 3.05 +2.4% 0.2%
IC12 18.09 20.21 +11.7% 3.6% yes
IC13 1.09 1.12 +2.4% 0.3%
IC14 48.32 49.65 +2.8% 0.8%

Geometric mean of branch/main: 1.014; median main run gap 0.7%, max 3.6%

graph-benchmark-ldbc-complex, lazy

Query main (ms) branch (ms) change main run gap plan changed
IC1 32.12 32.09 -0.1% 1.1%
IC2 25.73 25.28 -1.8% 1.4%
IC3 165.58 163.62 -1.2% 1.8% yes
IC4 12.97 12.98 +0.1% 0.5%
IC5 109.94 115.70 +5.2% 1.9%
IC6 23.70 24.17 +2.0% 0.9%
IC7 14.67 14.68 +0.0% 0.8%
IC8 3.31 3.33 +0.7% 0.6%
IC9 315.86 307.02 -2.8% 4.4%
IC10 34.31 34.44 +0.4% 0.8%
IC11 5.33 5.47 +2.7% 0.5%
IC12 21.42 23.57 +10.1% 6.8%
IC13 1.45 1.46 +0.8% 0.7%
IC14 50.83 51.18 +0.7% 0.1%

Geometric mean of branch/main: 1.012; median main run gap 0.9%, max 6.8%

Machine: Apple M5, 24 GB, macOS 26.6.2. graph-benchmark-ldbc f1d67be, graph-benchmark-ldbc-complex 552771f.

Closes #112.

🤖 Generated with Claude Code

The two-hop count rewrite copied each hop's relationship variable, so
a CSR hop over `[r1:FOLLOWS]` fetched edge rows nothing read. The
distinct-edges predicate is already the self-loop correction, so keep
the variable only when one of the hop's own filters reads it.
PruneCsrTargetColumns collected used columns by name over the whole
plan, so `b__name` fetched by a NodeLookup after the aggregate kept
the CSR hop under it taking `b__name` for every edge. Prune each
lookup's input against what that input itself outputs.
`RETURN b.id, b.name, count(a)` grouped by both columns, so the CSR
hop fetched `b.name` for every edge and the aggregate hashed it. When
the group keys include the id of a node with an in-memory node index,
group by the id alone and fetch the node's other grouped properties
after the aggregate, as `WITH b` already does. Grouping by properties
without the id keeps the plain plan.
The two-hop count grouped each hop's CSR expansion, one row per edge,
by its middle node in a hash aggregate. Re-root both hops at the middle
node and apply filters on it before expanding. Each hop's aggregate is
wrapped in a HopDegreesNode, which a new optimizer rule,
CountCsrDegrees, lowers after the hop's target filter has moved into
its CSR node (so after query parameters are bound): when the hop is a
CSR expansion with at most a far-end filter, the expansion counts each
node's neighbours in place, one row per node with its count and
self-loop count. Neighbours without a node row, or failing the far-end
filter, are not counted, as on the join path. Other hops keep the
aggregate.
…tern

`MATCH (x)-[:FOLLOWS]->(m)-[:LIVES_IN]->(c) RETURN m.name, c.city,
count(x)` carried one row per FOLLOWS edge through the second hop and
into the aggregate. On the CSR those rows come out spread over every
partition, so each partial aggregate built a table of every group,
keyed here on four strings: about 3x the join path's time.

When every aggregate is a plain count of rows and nothing else reads
the root variable or its relationship, count each m's root neighbours
once (a HopDegreesNode, counted in place on the CSR), plan the rest of
the pattern from m, join the counts on m's id and sum them. Conjuncts
over the root alone filter the counted neighbours. The join path keeps
its plan.
…p hop

A hop that starts from every node reads every edge either way, so the
CSR is only ahead there when the planner counts in place. Describe the
counting rewrites, what `degrees=` means in explain() output, and that
`edges=` is the relationship's edge count, not what the hop reads.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Deploying lance-csr with  Cloudflare Pages  Cloudflare Pages

Latest commit: 1b81089
Status: ✅  Deploy successful!
Preview URL: https://8754a314.lance-csr.pages.dev
Branch Preview URL: https://perf-csr-full-relationship-p.lance-csr.pages.dev

View logs

@prrao87
prrao87 marked this pull request as ready for review November 3, 2026 02:01
With use_csr=True, a hop runs on the CSR whenever the CSR can take it;
the fallback to a join covers hops it can't express, not slower ones,
and use_csr applies to the whole engine. Say so next to the fallback
statements and in the advice for whole-relationship hops.
@prrao87
prrao87 merged commit ae5244f into main Nov 3, 2026
8 checks passed
@prrao87
prrao87 deleted the perf/csr-full-relationship-plans branch November 3, 2026 02:15
prrao87 added a commit that referenced this pull request Nov 3, 2026
Patch release: the only change since 0.6.0 is #113, which changes
plans, not the Python or Rust API (no `!` commit).
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.

Make use_csr=True at least match the join path on full-relationship queries

1 participant