Repository navigation
perf(planner): walk pattern predicates on the csr and keep comprehension rows once - #108
Merged
Merged
Conversation
A list comprehension unnested each row's list and regrouped on every input column, including the list, so each element row carried a copy of the whole list. Unnest the list beside its positions instead, rebuild a list read from an input column once per row rather than grouping on it (projection pushdown drops it when unused), and fetch node-id elements through NodeLookup on in-memory node tables instead of joining the whole node table.
Each pattern predicate re-planned everything before it as a seed, matched the pattern from that copy and joined the marks back, doubling the plan beneath it. A predicate that is one path from a bound node, through anonymous nodes and anonymous relationships of distinct types, ending at an anonymous or bound node, is now answered by a PathExists node that walks the CSR from each row with early exit, requiring every node reached to exist in its node index. It appends the same mark, so nothing above changes; other patterns and engines without a CSR keep the relational marking.
Deploying lance-csr with
|
| Latest commit: |
4e3c88c
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://1515eb95.lance-csr.pages.dev |
| Branch Preview URL: | https://perf-in-place-pattern-predic.lance-csr.pages.dev |
prrao87
marked this pull request as ready for review
November 2, 2026 01:24
This was referenced Nov 2, 2026
This was referenced Nov 2, 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
LDBC IC10 was the slowest query where lance-csr trailed other engines on SF1: about 98 ms in memory, against 14 ms for Ladybug. Its graph hops take microseconds. The time went to two places where the planner lowers nested Cypher by flattening and regrouping:
[p IN posts WHERE ...]unnested each row's list and regrouped on every input column, including the list itself. Each of IC10's 30.8K element rows carried a copy of its friend's whole post list (95 MB throughUnnestExec), was hash-joined against the 1M-row Post table, and was hashed back together with that list as a group key.WHERE NOT (friend)-[:knows]-(person)and(p)-[:postHasTag]->()<-[:hasInterest]-(person)were each answered by planning everything before them a second time as a seed, matching the pattern from that copy, and left-joining a mark back. Each predicate doubles the plan below it, and DataFusion plans and runs every copy: IC10's plan had 203 operators, including 24 CSR expansions for a pattern of about six hops, and planning alone took about 37 ms.memorylazyMeans of four runs of each build,
main(d3c0192) vs this branch; full tables are under Validation. IC7'sisNewis a pattern predicate insideCASE. These are the only three queries in either suite whose plans change (IC3's differs only in the order of two conjuncts).Part of #18: IC10's plan drops from 209 to 63 physical operators. #18 stays open for IC1 and for
OPTIONAL MATCH, which still plans its input a second time.What changed
postsfromcollect(post)) is no longer a grouping key on the way back: it is rebuilt once per row from its elements, in order, null for a null list and empty for an empty one, and projection pushdown drops it when the comprehension doesn't read it. A list of nodes binds each element throughNodeLookupon in-memory node tables instead of a hash join against the whole table.PathExistsoperator for pattern predicates. A predicate that is one path from a bound node, through anonymous nodes and anonymous relationships of distinct single types, ending at an anonymous or bound node, with no innerWHERE, is answered per input row by walking the CSR with early exit. Every node it reaches must be in its label's node index, matching the relational plan's joins, so edges to missing nodes match nothing. It appends the sametrue/null mark as before, so filters andCASEabove it don't change. Other predicates, labels without a node index and engines without a CSR keep the relational plan, which the logical operator carries as its fallback; relationship-instance numbering stays in step whichever is planned. The column-pruning rule knows the operator reads only its start and end columns.PathExistsand the comprehension lowering in the DataFusion internals pages, withexplain()plans from a local build, and pattern predicates in the CSR guide's coverage table.No API change. Results are unchanged; queries of these shapes run faster, and
explain()showsPathExistsExecwhere it applies.Validation
PathExists) and without it (relational marking) return identical rows over self-loops, duplicate and mutual edges, posts with no tags, friends with no posts and dangling edges at every hop, and the test asserts which queries walk the CSR. Breaking the end-node comparison or the node-existence check makes it fail.cargo test --workspace --locked,cargo clippy --workspace --all-targets -- -D warnings,cargo fmt --all --check,make test(1665 passed),make lint,docs/check_snippets.py,zensical build --strictanduvx typos@1.26.0 .all pass locally. CI is pending.CONTRIBUTING.mdharness, release wheels ofmain(d3c0192) and this branch, both suites, both modes, builds alternated, run in two full passes (four runs of each build per mode). All 704 answer checks passed. graph-benchmark-ldbc 6f1b990, graph-benchmark-ldbc-complex 7f45ae2. Apple M5, 24 GiB, macOS 26.6.2.Reading the deltas: I diffed every query's physical plan between the two wheels. All 30 custom-suite plans and 11 of 14 complex-suite plans are identical, so their deltas are noise, not this change:
memory) has an identical plan and identical planning time (2.2 ms). It lands in a fast or a slow mode per process: three separate processes each, medians of 60 executions, gave 16.9 / 19.1 / 20.0 ms for a second build of unchangedmain, 19.0 / 18.9 / 19.1 formain, and 20.4 / 18.0 / 20.7 for this branch. Four runs per build weren't enough to average that out.memorydrifted +3.5% andlazy−2.7% over the same unchanged plans, with singlemainruns differing by up to 43%.[n IN nodes(path) | n.ID]now goes through the new lowering. In direct A/B runs (three processes per build, medians of 60), it took 49.0–50.6 ms against 47.1–48.4 ms formainand the control build, about +3%, split evenly between planning (24.4 vs 23.6 ms) and execution. The comprehension adds a few expressions to a plan DataFusion already spends 24 ms optimizing; the comprehension itself covers only a handful of paths.graph-benchmark-ldbc-complex, every query
graph-benchmark-ldbc-complex,
memory(4 runs of each build)main(ms)Geometric mean of branch/main: -10.0%. Spread across the four
mainruns: median 2.1%, max 5.3%.graph-benchmark-ldbc-complex,
lazy(4 runs of each build)main(ms)Geometric mean of branch/main: -11.2%. Spread across the four
mainruns: median 2.6%, max 8.3%.graph-benchmark-ldbc, every query (no plan changes)
graph-benchmark-ldbc,
memory(4 runs of each build)main(ms)Geometric mean of branch/main: +3.5%. Spread across the four
mainruns: median 4.2%, max 10.3%.graph-benchmark-ldbc,
lazy(4 runs of each build)main(ms)Geometric mean of branch/main: -2.7%. Spread across the four
mainruns: median 9.0%, max 42.9%.🤖 Generated with Claude Code, reviewed by @prrao87.