Repository navigation
feat(csr)!: store the csr inside each lance dataset and pack edge rows - #109
Merged
Merged
Conversation
Engines rebuild every CSR from a full scan of the relationship tables at start-up. With persist_csr=True, each full build of a Lance relationship dataset is written to <dataset>/_lance_csr/<version>.arrow, a zstd compressed Arrow IPC file, and later engines load it instead of building. Changes since the stored version apply as a delta under the usual 25% threshold, read from the file's recorded fragments, so this survives compaction and cleanup_old_versions(). Unusable files are rebuilt, and failed writes fall back to an ordinary build. delete_stored_csr() removes the stored files. csr_stats() reports update "loaded" for a CSR read from a file. CsrIndex now keeps its arrays as Arrow buffers, and Rust gains EngineOptions and CypherEngine::with_options. On GDC SF1 read lazily, engine start goes from 1.55 s to 0.58 s, the files take 150 MiB, and query times are unchanged.
Each CSR slot held its edge row as a raw 64-bit Lance row address. Edge rows are now packed in blocks of 128 values, each stored as its offset from the block's minimum in as few bits as the block needs, with every position still directly readable. On GDC SF1 that cuts the CSR's arrays from 613 to 419 MiB, and the whole in-memory CSR from 926 to 732 MiB. Plain hops never unpack edge rows. Neighbours stay a plain array: packing them too slowed lookup-heavy queries (custom Q30 by about 22%) for a smaller saving. Stored CSR files now hold the arrays as they sit in memory (format 2, still zstd compressed), so a load only decompresses and checks them: 134 MiB at SF1, and engine start drops from 0.58 to 0.46 s. The CSR guide now contrasts refresh() and optimize_csr() in a table, and its memory figures are measured rather than estimated.
Deploying lance-csr with
|
| Latest commit: |
706e181
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://9736eba7.lance-csr.pages.dev |
| Branch Preview URL: | https://feat-persist-csr.lance-csr.pages.dev |
#110) * refactor(engine): rename refresh() to checkout_latest() to match lance Lance moves a dataset to its newest version with checkout_latest() and has no refresh(). CypherEngine.refresh() does the same for every dataset the engine reads, so it is now checkout_latest(), in Rust and Python, which also pairs it with optimize_csr() the way Lance pairs checkout_latest() with optimize_indices(). refresh() stays as a deprecated alias: Python warns with DeprecationWarning, and Rust marks it #[deprecated]. Docs, examples, tests and benchmarks use the new name, and the lance_snapshot_refresh.py example is now lance_snapshots.py. * docs: say checkout_latest() follows lance and optimize_csr() rebuilds the csr
prrao87
marked this pull request as ready for review
November 2, 2026 17:50
Explain what checkout_latest() and optimize_csr() do and why before how, drop the remaining refresh wording, and add the what-and-why-first rule to the docs style guide.
prrao87
added a commit
that referenced
this pull request
Nov 2, 2026
Minor bump: #109 breaks the Python and Rust API (refresh() is renamed to checkout_latest() and CsrIndex's accessors changed), so until 1.0 it takes a minor version.
This was referenced Nov 3, 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.
User benefits
persist_csr=True, the CSR is computed once, stored inside each Lance relationship dataset, and loaded by later engines instead of being rebuilt in memory from every edge. On GDC SF1, engine start drops from about 1.7 s to 0.46 s. Writes made since the stored version are applied as a small delta, so the stored CSR stays useful as the data changes.memoryandlazymode, with no change in query times.persist_csr, nothing is written.delete_stored_csr()removes the stored files to go back to building in memory only.Why
Every engine builds its CSR from a full scan of the relationship tables, even when nothing changed since the last run: about 1.6 s of start-up on GDC SF1, repeated by every process. The CSR also held each edge's row as a raw 64-bit address, the larger half of its arrays.
This implements Stage 2 of #72 (store the CSR and load it at start-up) and the edge-row half of #79 (store CSR data in fewer bytes). Node indexes aren't stored yet, so #72 stays open for them; #79 stays open for neighbours (see below).
What changed
Stored CSRs (
persist_csr=True). An engine over Lance datasets stores each CSR it fully builds inside the relationship dataset's own directory, at_lance_csr/<version>.arrow, and later engines load it instead of building.checkout_latest(). The file records the fragments it was built from, so this keeps working after compaction andcleanup_old_versions(), which leaves the folder alone.checkout_latest()andoptimize_csr(), and older files are deleted. A file that doesn't parse or doesn't match is rebuilt, never trusted. A dataset that can't be written builds as before.delete_stored_csr()removes the stored files, for going back to in-memory builds only. The engine keeps the CSR it already holds.csr_stats()reportsupdate"loaded"for a CSR read from a file.UInt64arrays in lance-csr's own layout (documented in the internals pages); the arrays aren't meant to be read on their own.to_table(), are never stored, so the benchmarks'memorymode doesn't use this.Packed edge rows. Edge rows are now stored in blocks of 128 values: each block keeps its minimum and a bit width, and each value is its offset from the minimum, so any position stays directly readable. Only hops that name their relationship, and deletes applied by
checkout_latest(), unpack them.Tried and rejected: packing neighbours the same way roughly halved them, but every hop reads neighbours and lookup-heavy queries got slower: custom Q30 by about 22%. #79's
u32offset idea doesn't fit SF1 either, since its node keys span up to 2^45. Uncompressed files were 711 MiB at SF1; zstd brought them to 150 MiB, and storing the packed arrays to 134 MiB.User impact. Python gains
persist_csranddelete_stored_csr(), both experimental and off unless used. In Rust (marked experimental),EngineOptionsandCypherEngine::with_optionsare new,CsrIndex::edge_rows()returns an iterator instead of a slice, andCsrIndex::from_partsis replaced byfrom_packed. The CSR guide has a new "Storing the CSR" section, clearer guidance oncheckout_latest()againstoptimize_csr(), and measured memory figures.Renamed
refresh()tocheckout_latest()(merged in from #110), after Lance'sLanceDataset.checkout_latest(), so the engine pairscheckout_latest()withoptimize_csr()the way Lance pairscheckout_latest()withoptimize_indices().refresh()still works as a deprecated alias: Python warns withDeprecationWarningand Rust marks it#[deprecated].The root README is trimmed to the essentials. It no longer carries benchmark tables comparing engines, which go stale quickly; it links the two benchmark repositories instead, and
docs/sync_benchmarks.pynow updates only the docs site's benchmarks page.Validation
cargo fmt --all --check,cargo clippy --workspace --all-targets -- -D warningsandcargo test --workspacepass. New tests cover loading, catching up with appends and deletes, corrupt files,optimize_csr()storing a new base,delete_stored_csr(), and packed arrays (round trips, partial reads, and refusing inconsistent parts).make test(1672 passed) andmake lintpass, as do the strict Zensical build,docs/check_snippets.pyandtypos. New Python tests cover loading after compaction pluscleanup_old_versions(), rebuilding past the threshold, read-only datasets, PyArrow input and deletion.Start-up, GDC SF1 read lazily (23 relationship types, 17.3M edges), fresh processes, medians:
mainpersist_csroffpython/benchmarks/csr_persist.pymeasures the same on a synthetic graph: at 10M edges, a load took 55 ms against 1.18 s for a build (measured with the first stage, before edge rows were packed).Benchmarks. Release wheels from
main(d7634f8) and this branch (the code of 94769f1; only comments and docs changed after), run with theCONTRIBUTING.mdharness,mainand branch alternating twice per mode. Inlazymode a third build loads stored CSRs: the same branch wheel withpersist_csr=Trueset by asitecustomize.pyonPYTHONPATH, without editing the runners; a warm-up run stored the CSRs, and both timed runs loaded all 23. Every answer check passed. graph-benchmark-ldbc f1d67be, graph-benchmark-ldbc-complex 552771f. Apple M5, 24 GiB, macOS 26.6.2.main(geomean)mainmainrunsmemorylazymemorylazyQueries that moved beyond the gap between
mainruns:memory, +15.6%: the bimodal query from perf(planner): walk pattern predicates on the csr and keep comprehension rows once #108, which lands in a fast or slow mode per process. In a follow-up A/B with three processes per build,mainran 17.4/18.0/20.2 ms and the branch 17.6/17.7/18.3 ms.memory, +6.3%: the only one of these that reads edge rows. In a five-process A/B, the branch's median was 2.8% faster thanmain.memory, +6.2%: in the five-process A/B, +2.9% (25.6 to 27.6 ms against 25.1 to 25.7 ms). Its hops don't read edge rows, and the committed first stage showed the same, so I can't attribute it to packing. Small, but listed for review.memory, +4.0% (lazy+1.6%): far below the 22% when neighbours were packed. Custom Q27, loading, +11.4%: the in-memory CSR is the same as the branch's, which was -0.9%; it was -0.8% in an earlier run.Every query, both suites and modes (mean of two runs per build)
graph-benchmark-ldbc-complex (IC1 to IC14),
memorymain(ms)graph-benchmark-ldbc-complex (IC1 to IC14),
lazymain(ms)graph-benchmark-ldbc (30 custom queries),
memorymain(ms)graph-benchmark-ldbc (30 custom queries),
lazymain(ms)🤖 Generated with Claude Code