Skip to content

perf(cache): use fixed-size cache keys#7878

Open
Ali2Arslan wants to merge 4 commits into
lance-format:mainfrom
Ali2Arslan:perf/fixed-cache-keys
Open

perf(cache): use fixed-size cache keys#7878
Ali2Arslan wants to merge 4 commits into
lance-format:mainfrom
Ali2Arslan:perf/fixed-cache-keys

Conversation

@Ali2Arslan

@Ali2Arslan Ali2Arslan commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Replace heap-allocated, repeatedly hashed logical string keys with one opaque, canonical 16-byte InternalCacheKey.
  • Derive keys from stable type/schema identity plus typed fields using domain-separated BLAKE3, and migrate every in-tree cache-key producer to allocation-free typed encoding.
  • Keep CacheBackend object-safe while simplifying it around one physical key type; preserve codecs, accounting, clear, and Moka single-flight behavior.
  • Add deterministic cache/concurrency contracts, allocation guards, persistent-backend restart coverage, and paired Criterion benchmarks.

Closes #7832.

Stable key format

CACHE_KEY_FORMAT is blake3-128-v1:

  1. The root 32-byte namespace is generated with BLAKE3 derive_key using a fixed Lance context.
  2. Each with_key_prefix segment derives a new keyed namespace with explicit domain and length framing.
  3. Each entry hashes a stable type ID, author-defined schema ID/version, and tagged logical fields under that namespace.
  4. Field tags are defined by a #[repr(u8)] enum. Variable-width values are length-framed; integers are fixed-width little-endian; options, variants, sequences, fixed bytes, and variable bytes have distinct one-byte tags.
  5. The first 128 bits become the canonical backend key. InternalCacheKey::{as_bytes,into_bytes,from_bytes} are the persistence boundary.

Changing a key schema version intentionally produces a cold miss. Persistent backends should include CACHE_KEY_FORMAT in their physical namespace and allow entries from older formats to age out; there is no runtime legacy-key fallback.

The digest is a cache identity, not an authentication or authorization mechanism. Deterministic namespace keys are not secret. A 128-bit digest has approximately 64 bits of generic birthday-collision resistance and 128 bits of targeted preimage resistance. This change does not add a FIPS mode: #7832 selects BLAKE3 and Lance has no existing FIPS configuration surface.

Intentional backend API break

This removes APIs that require retaining logical strings or a secondary inventory:

  • CacheBackend::invalidate_prefix
  • backend key inventory / LanceCache::keys
  • readable key and prefix accessors
  • session cache-key inventory methods

Use clear for explicit invalidation, or derive/version a new namespace when a logical scope changes.

Custom backend migration:

  • Store/copy InternalCacheKey directly, or persist key.into_bytes() as exactly 16 bytes.
  • Reconstruct keys with InternalCacheKey::from_bytes when needed.
  • Route serialized values with CacheCodec::type_id() instead of inferring value type from a logical key string.
  • Replace with_backend_and_prefix with with_backend(...).with_key_prefix(...).
  • Remove prefix scans and key-string parsing. The CacheBackend trait remains non-generic and object-safe.

Existing out-of-tree CacheKey / UnsizedCacheKey implementations retain a source-compatible default bridge through key(). Performance-sensitive implementations should define a stable CacheKeySchema and override write_key; all current in-tree producers do so.

Correctness and persistence coverage

The proof suite covers:

  • official and golden BLAKE3 vectors, exact builder output, framing boundaries, endianness, type/schema/namespace separation, and options/variants/sequences;
  • default sized and unsized string bridges plus schema-version cold misses;
  • shared strong/weak cache state, expired weak handles, no-cache behavior, custom backends, Moka weights scaled safely above 4 GiB, and contextual type-collision misses/errors;
  • deterministic single-flight success, error, and owner-cancellation behavior with contenders explicitly parked before release/abort;
  • zero allocations for complete production-shaped typed page and optional-UUID keys after warm-up;
  • deletion-file cache identity across distinct storage bases;
  • a shared serializing backend that retains only bytes and opaque keys across restart and always decodes with the lookup codec;
  • BTree and IVF restart queries that prove serialized state/partitions are reused, assert vector recall, and perform zero index I/O once non-serializable readers are reconstructed, plus existing FTS/metadata/scalar integration coverage.

The removed unstable IVF cache_key_prefix protobuf payload is reserved by field number and name. Its codec version is unchanged because protobuf removal is wire-compatible and the new physical-key format already guarantees a cold miss.

Benchmarks

Three independent Criterion passes used release-with-debug, 100 samples, Rust 1.97.0, and an AMD Ryzen 9 3900X under x86_64 WSL2. Both paths include the backend's outer hash. Reported ranges compare median time for the fixed path against the benchmark-local implementation of the previous string-key path:

  • Long production-shaped key preparation: 2.8%–10.3% faster.
  • Short isolated key preparation: 139%–156% slower; this exposes BLAKE3's fixed setup cost instead of hiding it. The motivating long-prefix workload improves.
  • Strong warmed hits: 7.7%–16.6% faster.
  • Weak warmed hits: 1.4%–6.0% faster.
  • Bounded rotating inserts with prebuilt values: between 8.9% faster and 3.0% slower (effectively neutral; median pass was 1.3% faster).
  • Typed key preparation: 0 allocations after warm-up.

Namespace derivation is benchmarked separately so one-time scope setup is not folded into per-entry preparation.

Validation

Base: a3c6fce816befb7072505fbe05cc55cd205a171e

Passed after rebasing onto that base and again after review follow-ups:

  • cargo fmt --all -- --check
  • CARGO_INCREMENTAL=0 cargo check --workspace --tests --benches --locked
  • CARGO_INCREMENTAL=0 cargo clippy --all --tests --benches -- -D warnings
  • CARGO_INCREMENTAL=0 cargo test --workspace --locked
  • CARGO_INCREMENTAL=0 cargo +1.91.0 check --workspace --tests --benches --locked
  • CARGO_INCREMENTAL=0 cargo check --manifest-path python/Cargo.toml --locked
  • CARGO_INCREMENTAL=0 cargo check --manifest-path java/lance-jni/Cargo.toml --locked
  • targeted lance-core cache/allocation and Lance serializing-restart tests on Rust 1.91
  • error-path single-flight stress test repeated 1,000 times
  • three complete, symmetrically hashed paired benchmark passes

All three lockfiles contain only the intentional BLAKE3 dependency change (plus the upstream release-version updates already present in the base).

Interaction with open cache work

Made with Cursor

Ali2Arslan and others added 2 commits July 21, 2026 00:06
Replace logical string keys with domain-separated BLAKE3-128 identities to remove long-prefix hashing and simplify persistent backends.

Co-authored-by: Cursor <cursoragent@cursor.com>
Preserve the existing examples and section markers while adapting the cache implementation.

Co-authored-by: Cursor <cursoragent@cursor.com>
@github-actions

Copy link
Copy Markdown
Contributor

Important

This PR touches the Lance format specification.

Substantive changes to the format specification — the .proto definitions
and the spec docs under docs/src/format/ — require a PMC vote before merge.
Minor edits such as typo fixes, wording, or formatting are excluded; use your
judgment.

If this is a meaningful format change:

  • Start a vote following the Lance community voting process.
    Format specification modifications need 3 binding +1 votes (excluding the
    proposer), held on GitHub Discussions, with a minimum voting period of 1 week.
  • Once the vote passes, link the completed vote in this PR. It should not be
    merged until the vote is linked.

@github-actions github-actions Bot added A-python Python bindings A-index Vector index, linalg, tokenizer A-java Java bindings + JNI A-deps Dependency updates A-encoding Encoding, IO, file reader/writer performance labels Jul 21, 2026
@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This change replaces string-based cache keys with versioned typed BLAKE3-derived keys, updates cache backend and lifecycle APIs, migrates cache-key implementations across Lance components, removes IVF prefix persistence, and adds serializing-backend, allocation, and benchmark coverage.

Changes

Typed cache key migration

Layer / File(s) Summary
Key and cache foundation
Cargo.toml, rust/lance-core/src/cache/*
Adds BLAKE3-based canonical key construction, typed schemas, namespaces, shared cache state, updated backend APIs, and revised Moka sizing.
Typed key implementations
rust/lance-encoding/..., rust/lance-file/..., rust/lance-index/..., rust/lance/src/...
Adds explicit schemas and structured serialization for dataset, metadata, scalar-index, inverted-index, vector-index, and session cache keys.
Persistence and cache API migration
rust/lance-index/protos-cache/cache.proto, rust/lance/src/index/vector/ivf/*, rust/lance/src/session.rs, rust/lance/src/utils/test/*, rust/lance/src/dataset/tests/*
Removes IVF cache prefixes and cache-key inventory APIs, adds SerializingCacheBackend, and updates restart/prewarm coverage.
Validation and benchmarks
rust/lance-core/benches/cache_keys.rs, rust/lance-core/tests/cache_key_allocations.rs, rust/lance-core/Cargo.toml
Adds Criterion benchmarks and verifies zero post-warmup allocations for typed key preparation.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CacheKey
  participant LanceCache
  participant SerializingCacheBackend
  participant DatasetQuery
  CacheKey->>LanceCache: provide schema and typed fields
  LanceCache->>SerializingCacheBackend: lookup encoded InternalCacheKey
  SerializingCacheBackend-->>LanceCache: deserialize cached entry
  LanceCache-->>DatasetQuery: return cached index or metadata
Loading

Possibly related PRs

Suggested labels: enhancement

Suggested reviewers: wjones127, xuanwo, westonpace, jackye1995

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 47.06% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the cache-key refactor.
Description check ✅ Passed The description matches the changeset and describes the cache-key migration and benchmarks.
Linked Issues check ✅ Passed The PR implements the fixed-size BLAKE3-128 cache-key design, versioning, namespaces, and the removals requested by #7832.
Out of Scope Changes check ✅ Passed The diff stays focused on cache-key refactoring, related tests, and benchmarks without unrelated feature work.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Jul 21, 2026

Copy link
Copy Markdown

@wjones127
wjones127 self-requested a review July 21, 2026 15:14
.unwrap_or(u32::MAX)
entry_weight(key, entry.size_bytes)
})
.support_invalidation_closures()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no longer needed, and saves us some (accounting) space inside moka, but I can add it back if we need it for some reason

/// - **type_name**: distinguishes different value types stored under the same
/// user key (e.g. `"Vec<IndexMetadata>"`)
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub struct InternalCacheKey {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I opted for reworking InternalCacheKey directly instead of having 2 different formats due to the the amount of breaking API changes this PR was already introducing, and the tests assert the stability.
This does mean that all existing persistent backend will see new keys on deployment, but the contract is explicit that going forward format changes are expected and should lead to cache misses (with persistent backends responsible for cleaning up the stale keys - likely through TTLs).
If we really want to support both layouts and have the caller pick, I can add that back in (still requires breaking API changes) - at the cost of some wrapper structs that we'll need to maintain. IMHO only really worth it if we envision a future where we'll want to allow different cache key layouts.

Account for large entries without u32 truncation and include storage base in deletion keys. Strengthen migration, benchmark, and persistence proofs discovered during review.

Co-authored-by: Cursor <cursoragent@cursor.com>
//! protocol produces cold misses instead of aliases. Persistent or tiered
//! backends can route serializable values with [`CacheCodec::type_id`].
//!
//! Prefix invalidation and key inventory are intentionally not part of this

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can remove this paragraph, if we don't need to carry historical context

.try_into()
.unwrap_or(u32::MAX)
.max_capacity(capacity_weight)
.weigher(move |key: &InternalCacheKey, entry: &MokaCacheEntry| {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bit of a drive-by here to not cap an entry weight to u32::MAX (which I know we've said before that entries should not get this large) but since we've seen this in the past with our indexes lookup files, I think this is probably worth it, just as defensive posturing for the future?

Represent stable field discriminants as a repr(u8) enum so their relationship and one-byte encoding are explicit without changing key bytes.

Co-authored-by: Cursor <cursoragent@cursor.com>
@Ali2Arslan
Ali2Arslan marked this pull request as ready for review July 21, 2026 17:29
@@ -0,0 +1,365 @@
// SPDX-License-Identifier: Apache-2.0

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file can be removed before merging, if we want.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
rust/lance-index/src/scalar/bitmap.rs (1)

150-168: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Document why write_key uses row_offset instead of value.

write_key intentionally diverges from key() here: key() hashes value, but write_key() only encodes row_offset. This is correct — row_offset is a bijective stand-in for value within a single loaded index (index_map: BTreeMap<Value, usize>) — and it's covered by test_bitmap_cache_key_uses_row_offset_identity. However, every other CacheKey/UnsizedCacheKey impl touched in this PR mirrors the exact fields used in key(), so this is the one outlier. A short comment would prevent a future refactor from "fixing" this back to hashing value (which would be more expensive and unnecessary) or breaking the row_offset-uniqueness invariant it relies on.

📝 Suggested comment
     fn schema() -> CacheKeySchema {
         CacheKeySchema::new("lance.scalar.bitmap-row-offset-key", 1)
     }

+    // `row_offset` is a bijective stand-in for `value` within a loaded index
+    // (each entry in `index_map` has a unique row_offset), so it alone is
+    // sufficient physical identity — cheaper to encode than the arbitrary
+    // `ScalarValue`. See `test_bitmap_cache_key_uses_row_offset_identity`.
     fn write_key(&self, builder: &mut KeyBuilder) {
         builder.write_u64(self.row_offset);
     }

As per coding guidelines, "Add doc comments to magic constants, thresholds, and non-obvious transformation functions, explaining what the value represents and why it was chosen."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/lance-index/src/scalar/bitmap.rs` around lines 150 - 168, Add a concise
comment to BitmapKey::write_key explaining that row_offset is a bijective,
cheaper identity for value within a loaded index, so it preserves uniqueness
without encoding value directly. Keep the existing write_u64(self.row_offset)
behavior unchanged.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@rust/lance-index/src/scalar/bitmap.rs`:
- Around line 150-168: Add a concise comment to BitmapKey::write_key explaining
that row_offset is a bijective, cheaper identity for value within a loaded
index, so it preserves uniqueness without encoding value directly. Keep the
existing write_u64(self.row_offset) behavior unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: QUIET

Plan: Pro Plus

Run ID: f539033c-e139-4816-b83c-9a84c9ee9313

📥 Commits

Reviewing files that changed from the base of the PR and between a3c6fce and 2c66b2b.

⛔ Files ignored due to path filters (3)
  • Cargo.lock is excluded by !**/*.lock
  • java/lance-jni/Cargo.lock is excluded by !**/*.lock
  • python/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (32)
  • Cargo.toml
  • rust/lance-core/Cargo.toml
  • rust/lance-core/benches/cache_keys.rs
  • rust/lance-core/src/cache/backend.rs
  • rust/lance-core/src/cache/codec.rs
  • rust/lance-core/src/cache/key.rs
  • rust/lance-core/src/cache/mod.rs
  • rust/lance-core/src/cache/moka.rs
  • rust/lance-core/tests/cache_key_allocations.rs
  • rust/lance-encoding/src/encodings/logical/primitive.rs
  • rust/lance-file/src/previous/reader.rs
  • rust/lance-file/src/reader.rs
  • rust/lance-index/protos-cache/cache.proto
  • rust/lance-index/src/scalar/bitmap.rs
  • rust/lance-index/src/scalar/btree.rs
  • rust/lance-index/src/scalar/inverted/index.rs
  • rust/lance-index/src/scalar/label_list.rs
  • rust/lance-index/src/scalar/lance_format.rs
  • rust/lance-index/src/scalar/ngram.rs
  • rust/lance-index/src/scalar/registry.rs
  • rust/lance-index/src/scalar/rtree.rs
  • rust/lance/src/dataset/fragment.rs
  • rust/lance/src/dataset/tests/dataset_index.rs
  • rust/lance/src/index.rs
  • rust/lance/src/index/vector/ivf.rs
  • rust/lance/src/index/vector/ivf/partition_serde.rs
  • rust/lance/src/index/vector/ivf/v2.rs
  • rust/lance/src/session.rs
  • rust/lance/src/session/caches.rs
  • rust/lance/src/session/index_caches.rs
  • rust/lance/src/utils/test.rs
  • rust/lance/src/utils/test/serializing_cache.rs
💤 Files with no reviewable changes (1)
  • rust/lance/src/index/vector/ivf/partition_serde.rs

// type is generic over the quantizer; the proto envelope still provides
// additive evolution for the surrounding fields.
string quantizer_metadata_json = 7;
string cache_key_prefix = 8;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can revert this, and just have cache_key_prefix be used, instead of removing/deprecating at the proto level.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-deps Dependency updates A-encoding Encoding, IO, file reader/writer A-index Vector index, linalg, tokenizer A-java Java bindings + JNI A-python Python bindings performance

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fixed-size (u128) cache keys

1 participant