Repository navigation
perf(csr): match the join path on whole-relationship queries - #113
Merged
Merged
Conversation
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.
Deploying lance-csr with
|
| 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 |
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
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).
This was referenced Nov 4, 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
#112 showed
use_csr=Truelosing to the join path on every graph-benchmark query that reads the wholeFOLLOWSrelationship (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
r1/r2into the hops it plans unless a hop's own filter reads them, so the CSR doesn't fetch edge rows nothing uses.PruneCsrTargetColumnscollected used columns by name over the whole plan, sob__namefetched by aNodeLookupabove an aggregate kept the hop under it takingb__namefor 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.RETURN b.id, b.name, count(*)now groups byb__idand fetchesb.nameafter the aggregate withNodeLookup, asWITH balready did. It applies under the same conditions as before (in-memory node index). Grouping by properties without the id keeps today's plan.degrees=[__n1, __s1]inexplain()): one row per node instead of one per edge. It also counts self-loops for the correction, and a far-end filter such asc.age > $age_2is 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 aHopDegreesNode, 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.xor its relationship,MATCH (x)-[:R]->(m)... RETURN ..., count(x)counts eachm'sxneighbours in place, plans the rest fromm, joins the counts onm'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.degrees=means, and thatedges=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 thatuse_csrapplies to the whole engine.Validation
cargo test --workspace,cargo clippy --workspace --all-targets -- -D warningsandcargo fmt --all --checkpass, and each commit was checked on its own (fmt, clippy, thelance-csrunit tests and the strict docs build).make test(1678 passed) andmake lintpass.zensical build --strict,typosandcheck_snippets.pypass. 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.idplus other properties, including duplicates andOPTIONAL MATCH, against grouping by properties alone.graph-benchmark (100K
Person, 2.4MFOLLOWS, 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 withoutuse_csr, and runner settings shouldn't be edited, so this used the script rather than its pytest harness. Queries are those of 7ca5d17.use_csr=Trueonmainuse_csr=Trueon this branchq3 to q7 are unchanged (each within 0.1 ms).
CONTRIBUTING suites (GDC SF1, release wheels from
maine2339cc and this branch,uv run --with, both modes, alternating main/branch twice, settings as inCONTRIBUTING.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 byfriend.IDand looks up the names). The two rows outside 5%:forum.titleandforum.creationDateper edge. +5% in lazy with an identical plan.mainalone measured 17.3 and 20.0 ms in two processes. On this branch, the old grouping (forced withfriend.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
Geometric mean of branch/main: 1.010; median main run gap 1.0%, max 5.0%
graph-benchmark-ldbc, lazy
Geometric mean of branch/main: 1.014; median main run gap 2.0%, max 9.3%
graph-benchmark-ldbc-complex, memory
Geometric mean of branch/main: 1.014; median main run gap 0.7%, max 3.6%
graph-benchmark-ldbc-complex, lazy
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