Skip to content

perf(cache): opt-in quick_cache backend for contention-free reads#7953

Open
LuQQiu wants to merge 4 commits into
lance-format:mainfrom
LuQQiu:lu/quick_cache_backend
Open

perf(cache): opt-in quick_cache backend for contention-free reads#7953
LuQQiu wants to merge 4 commits into
lance-format:mainfrom
LuQQiu:lu/quick_cache_backend

Conversation

@LuQQiu

@LuQQiu LuQQiu commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Problem

moka records every cache hit into a single global read-op channel (input for its LRU/TinyLFU policy), and readers trigger inline housekeeping when the channel passes its flush point. Each warm hit therefore touches globally-shared cache lines (record_read_op: channel length check + housekeeper try-lock CAS + try_send). At index-cache read rates this collapses:

Pure get microbenchmark, 50K entries x 4KB, 100% hits, 320-core host:

readers moka quick_cache
1 2.87 Mops/s (348 ns/op) 12.05 Mops/s (83 ns/op)
64 0.74 (1345 ns) 11.03 (91 ns)
256 0.31 (3266 ns) — negative scaling 13.80 (72 ns) — flat

A warm fts query performs ~2000 cache reads (posting groups + row-id columns across 350 partitions), so moka's ceiling imposes a hard qps cap. Background run_pending_tasks draining was tested and does not help (+7%): at these rates the 384-slot channel refills past its 64-entry flush point every ~0.13 ms, and the per-read atomics remain. This is the known, still-open moka issue moka-rs/moka#498.

Change

An opt-in CacheBackend backed by quick_cache, whose S3-FIFO/CLOCK-family policy records a hit by setting one reference bit on the entry itself — no read log, no reader-triggered housekeeping. Single-flight get_or_insert maps to get_value_or_guard_async; weighted capacity maps to Weighter (u64 weights). LANCE_CACHE_BACKEND=quick opts in; moka remains the default.

Results on a real workload

100M-doc 42-language corpus, 5-term match_any (OR), k=100, warm + prewarmed, 320-core node, exact scoring, identical results; same binary (with #7950's chunked search), only the backend differs:

concurrency moka quick_cache
16 427.6 qps / 37 ms / 22% CPU 489.3 qps / 33 ms / 25% CPU
64 220.8 qps / 290 ms / 29% CPU 1238.7 qps / 52 ms / 79% CPU
128 180.7 qps / 710 ms / 47% CPU 1340.6 qps / 96 ms / 93% CPU
192 167.7 qps / 1148 ms / 66% CPU 1193.9 qps / 161 ms / 86% CPU

The moka column inverts with concurrency (contention); the quick_cache column peaks at 93% CPU doing scoring work.

Semantics and scope

  • Correctness does not depend on eviction order anywhere in lance: entries may be evicted at any time and reload via single-flight (covered by existing fts eviction/reload tests).
  • Feature parity for what lance actually uses: weighted capacity, async single-flight, iteration, prefix invalidation (implemented via iteration; it has no production call sites today). moka-only features lance does not use: TTL, time-to-idle, eviction listeners.
  • Eviction policy differs (S3-FIFO vs TinyLFU): comparable hit ratios on published traces, and scan-resistant. Flipping the default should follow a hit-rate comparison under forced memory pressure on representative workloads; until then this is opt-in.

Tests

New backend test exercises roundtrip, single-flight load dedup, weighted-capacity enforcement under overfill, and clear, through the standard LanceCache API. cargo test -p lance-core green.

Shard sizing (second commit)

quick_cache splits its weight budget evenly across shards with no cross-shard borrowing, and an entry heavier than ~its shard's share is silently refused admission (maintainer-confirmed behavior, arthurprs/quick-cache#55). Its default shard count (available_parallelism × 4, rounded up to a power of two — 2048 on a 320-core host) is tuned for many-small-entry workloads; index-cache entries are MB-to-hundreds-of-MB posting groups, so small shares risk silent rejection and per-shard imbalance waste.

The backend now derives the shard count as min(cpus / 2, capacity / 4 GiB), rounded down to a power of two and clamped to [1, 1024]. The cpu term bounds lock contention (more shards than concurrent threads buys nothing), and the capacity term keeps every shard's share >= 4 GiB so the largest index-cache entries stay admissible — the same shape as RocksDB's block-cache default (capacity / min_shard_size, capped).

Shard-count sweep (pure get, 50K × 4KB entries, 320-core host, 256 readers) shows contention is a non-issue at low shard counts — even a single-shard quick_cache is 10x moka, and throughput plateaus from ~64 shards:

shards 1 4 16 64 128 256-2048 (moka)
Mops/s @ 256 readers 6.1 11.3 11.9 12.2 13.5 ~13.4-14.2 0.63

On a 320-core host with 960 GiB capacity this yields 128 shards (7.5 GiB share, on the throughput plateau); a 4-core host with 8 GiB yields 2 shards (4 GiB share) — entries up to ~3.9 GiB stay admissible everywhere. A standalone matrix bench across machine profiles (workers 1-320, capacities 2 GiB-1.25 TiB, cache/working-set ratios 0.1-2.0, lance-style 90% 1 MiB + 10% 128 MiB entry mix) confirms every landing point clears the measured demand (~3 Mops of cache gets at 1300 qps FTS) by 2-10x under eviction pressure and 20-100x warm, while moka baselines sit at 0.2-4.7 Mops and degrade as workers grow.

End-to-end validation (real 100M-doc 42-language FTS index, prewarmed, c128, 90s/arm, 960 GiB cache): shard counts 1 / 16 / 64 / 128 / 512 / 2048 all measure 1178-1323 qps — inside run-to-run noise — versus ~180 qps on moka. Warm hits take no lock (atomic CLOCK bit), so shard count only affects admission and eviction-phase contention; sizing by admission margin is free. The formula also auto-sized the co-resident 36 GiB metadata cache to 8 shards.

moka records every cache hit into one global read-op channel and lets
readers trigger inline housekeeping; at high concurrent hit rates
(hundreds of thousands of reads/sec) those shared-cache-line atomics
collapse read throughput (measured: negative scaling from 2.9 to 0.3
Mops/s between 1 and 256 readers on a 320-core host).

Add a CacheBackend backed by quick_cache, whose S3-FIFO policy records
a hit with one reference bit on the entry itself: reads stay 72-152
ns/op flat through 256 readers. LANCE_CACHE_BACKEND=quick opts in;
moka remains the default. On a warm 100M-doc fts workload the swap
lifts c128 from 181 to 1341 qps (7.4x) with identical results.
@github-actions github-actions Bot added A-deps Dependency updates performance labels Jul 23, 2026
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds an experimental weighted quick_cache backend, implements CacheBackend operations and tests, and enables LanceCache to select it through the LANCE_CACHE_BACKEND environment variable.

Changes

Quick cache backend

Layer / File(s) Summary
Backend foundation
rust/lance-core/Cargo.toml, rust/lance-core/src/cache/quick.rs, rust/lance-core/src/cache/moka.rs
Adds the quick_cache dependency, weighted cache storage, and parent-module access to key footprint calculation.
Cache operations
rust/lance-core/src/cache/quick.rs
Implements retrieval, insertion, singleflight loading, prefix invalidation, clearing, and size metrics.
Backend selection and validation
rust/lance-core/src/cache/mod.rs, rust/lance-core/src/cache/quick.rs
Exports and selects QuickCacheBackend when configured, with tests covering loading, weighting, eviction, and clearing.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant LanceCache
  participant QuickCacheBackend
  participant quick_cache
  participant Loader
  Caller->>LanceCache: construct with capacity
  LanceCache->>QuickCacheBackend: select when LANCE_CACHE_BACKEND=quick
  Caller->>QuickCacheBackend: get_or_insert(key)
  QuickCacheBackend->>quick_cache: get_value_or_guard_async(key)
  quick_cache->>Loader: load on cache miss
  Loader-->>QuickCacheBackend: CacheEntry and size_bytes
  QuickCacheBackend-->>Caller: entry and hit/miss flag
Loading

Possibly related PRs

Suggested labels: enhancement

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% 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
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main change: adding an opt-in quick_cache backend for LanceCache reads.
Description check ✅ Passed The description is directly about the added quick_cache backend and its performance motivation, tests, and scope.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Note

Quiet mode is enabled, so only the most important comments were posted inline. Other review comments are grouped below.

🟡 Other comments (1)
rust/lance-core/src/cache/quick.rs-35-57 (1)

35-57: 🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

Add missing doc comments and make the estimate capacity-aware.

QuickCacheBackend and QuickCacheBackend::with_capacity are public API but lack doc comments. Also, estimated_items_capacity should roughly match weight_capacity / average item weight; deriving it from capacity will size the cache for small vs. large capacity values instead of always assuming ~1M average-size entries.

🤖 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-core/src/cache/quick.rs` around lines 35 - 57, The public
QuickCacheBackend type and its with_capacity constructor need documentation, and
the fixed estimated_items value must become capacity-aware. Add concise doc
comments to QuickCacheBackend and QuickCacheBackend::with_capacity, then derive
the item estimate from capacity and the expected average entry weight before
passing it to Cache::with_weighter, preserving the existing cache initialization
behavior.
🧹 Nitpick comments (4)
rust/lance-core/src/cache/quick.rs (3)

162-214: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

No test coverage for invalidate_prefix.

The test covers roundtrip, singleflight dedup, weighted eviction, and clear, but not invalidate_prefix, which is a new CacheBackend operation implemented in this file.

As per coding guidelines: "Every bugfix and feature must have corresponding tests."

🤖 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-core/src/cache/quick.rs` around lines 162 - 214, Add coverage for
the new invalidate_prefix operation in
test_quick_backend_roundtrip_singleflight_and_eviction: insert multiple entries
sharing a prefix plus at least one non-matching entry, invoke invalidate_prefix
through the cache API, and assert matching entries are removed while the
non-matching entry remains accessible.

Source: Coding guidelines


29-33: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

EntryWeighter omits key footprint, unlike MokaCacheBackend's weigher.

MokaCacheBackend::with_capacity weighs key_footprint(key).saturating_add(entry.size_bytes), but this weigher only counts value.size_bytes. Since size_bytes()/approx_size_bytes() are documented as "Total weighted size in bytes of all stored entries" / used for DeepSizeOf memory reporting, switching backends changes what that number actually represents — undermining the stated goal of comparing hit-rate/memory behavior between the two backends.

🤖 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-core/src/cache/quick.rs` around lines 29 - 33, Update
EntryWeighter::weight to include the InternalCacheKey footprint using the same
key_footprint calculation as MokaCacheBackend, then add the QuickEntry size with
saturating arithmetic while preserving the minimum weight behavior for
zero-sized entries. Ensure quick-cache sizing and reporting represent both key
and value memory consistently across backends.

95-105: 🚀 Performance & Scalability | 🔵 Trivial

Full-cache scan on every invalidate_prefix call.

This iterates and collects every key in the cache, then removes matches one by one — O(n) in total cache size per call, regardless of how many entries actually match. This mirrors the design limits of quick_cache (no native prefix index), so may be an accepted tradeoff, but worth being aware of for caches with many entries under frequent invalidation (e.g. per-fragment cache invalidation on write-heavy workloads).

🤖 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-core/src/cache/quick.rs` around lines 95 - 105, Acknowledge the
full-cache scan in invalidate_prefix as an inherent quick_cache limitation and
assess whether this path is acceptable for frequent prefix invalidation; if
optimization is required, replace the per-call full iteration with a suitable
prefix-aware indexing or invalidation strategy while preserving removal of every
matching InternalCacheKey.
rust/lance-core/Cargo.toml (1)

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

Gate quick_cache behind a Cargo feature flag.

quick_cache is explicitly experimental/opt-in (enabled only via LANCE_CACHE_BACKEND=quick), but it's added as an unconditional dependency. As per coding guidelines, rust/**/Cargo.toml: "Gate optional or domain-specific dependencies behind Cargo feature flags and prefer separate crates for domain functionality."

♻️ Suggested approach
-quick_cache = "0.6"
+quick_cache = { version = "0.6", optional = true }

Then gate mod quick; / pub use quick::QuickCacheBackend; in mod.rs and the LANCE_CACHE_BACKEND branch behind #[cfg(feature = "quick-cache")], and add the feature to [features].

Also worth double-checking: the line above (moka.workspace = true) uses workspace-managed dependency versioning; consider whether quick_cache should be declared in the workspace [workspace.dependencies] for consistency (though num_cpus right below shows this isn't uniformly enforced in this file).

🤖 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-core/Cargo.toml` at line 31, Gate the quick_cache dependency
behind a new quick-cache feature in rust/lance-core/Cargo.toml, preserving
workspace dependency conventions where applicable. Update the quick module
exports in mod.rs and the LANCE_CACHE_BACKEND quick branch to compile only with
#[cfg(feature = "quick-cache")], while keeping the existing behavior for other
cache backends 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.

Other comments:
In `@rust/lance-core/src/cache/quick.rs`:
- Around line 35-57: The public QuickCacheBackend type and its with_capacity
constructor need documentation, and the fixed estimated_items value must become
capacity-aware. Add concise doc comments to QuickCacheBackend and
QuickCacheBackend::with_capacity, then derive the item estimate from capacity
and the expected average entry weight before passing it to Cache::with_weighter,
preserving the existing cache initialization behavior.

---

Nitpick comments:
In `@rust/lance-core/Cargo.toml`:
- Line 31: Gate the quick_cache dependency behind a new quick-cache feature in
rust/lance-core/Cargo.toml, preserving workspace dependency conventions where
applicable. Update the quick module exports in mod.rs and the
LANCE_CACHE_BACKEND quick branch to compile only with #[cfg(feature =
"quick-cache")], while keeping the existing behavior for other cache backends
unchanged.

In `@rust/lance-core/src/cache/quick.rs`:
- Around line 162-214: Add coverage for the new invalidate_prefix operation in
test_quick_backend_roundtrip_singleflight_and_eviction: insert multiple entries
sharing a prefix plus at least one non-matching entry, invoke invalidate_prefix
through the cache API, and assert matching entries are removed while the
non-matching entry remains accessible.
- Around line 29-33: Update EntryWeighter::weight to include the
InternalCacheKey footprint using the same key_footprint calculation as
MokaCacheBackend, then add the QuickEntry size with saturating arithmetic while
preserving the minimum weight behavior for zero-sized entries. Ensure
quick-cache sizing and reporting represent both key and value memory
consistently across backends.
- Around line 95-105: Acknowledge the full-cache scan in invalidate_prefix as an
inherent quick_cache limitation and assess whether this path is acceptable for
frequent prefix invalidation; if optimization is required, replace the per-call
full iteration with a suitable prefix-aware indexing or invalidation strategy
while preserving removal of every matching InternalCacheKey.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: QUIET

Plan: Pro Plus

Run ID: 8adc8a79-7d19-48d7-9a5b-0f95643f2b06

📥 Commits

Reviewing files that changed from the base of the PR and between fb88621 and 81cef54.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (3)
  • rust/lance-core/Cargo.toml
  • rust/lance-core/src/cache/mod.rs
  • rust/lance-core/src/cache/quick.rs

@codecov

codecov Bot commented Jul 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.73585% with 13 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
rust/lance-core/src/cache/quick.rs 88.00% 12 Missing ⚠️
rust/lance-core/src/cache/mod.rs 80.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@LuQQiu
LuQQiu force-pushed the lu/quick_cache_backend branch from 1a96270 to 1447211 Compare July 23, 2026 19:57

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
rust/lance-core/src/cache/quick.rs (2)

208-219: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Ensure the eviction test proves admission.

If every fill-* insertion is rejected, the existing entries still satisfy size_bytes <= CAPACITY and size() < 258, so this test passes without caching any fill item. Assert that at least one fill entry was admitted before checking eviction bounds.

🤖 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-core/src/cache/quick.rs` around lines 208 - 219, Update the fill
loop in the eviction test to track whether any insert_with_key call for a fill-*
key was admitted, and assert that at least one fill entry was cached before the
existing size_bytes and size eviction-bound assertions. Preserve the current
overfill scenario and bounds checks.

193-206: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the single-flight test concurrent.

The loop awaits each call before starting the next, so the second call is guaranteed to be a cache hit. Start multiple tasks simultaneously against an uncached key, block the loader with a barrier/notification, and assert loads == 1.

🤖 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-core/src/cache/quick.rs` around lines 193 - 206, The single-flight
test currently runs requests sequentially, so it does not verify concurrent
loading. Update the test around get_or_insert_with_key to spawn multiple tasks
using the same uncached key, synchronize their start, and block the loader with
a barrier or notification until all callers are active; then release it, await
all results, and assert loads remains 1.
🤖 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.

Inline comments:
In `@rust/lance-core/src/cache/quick.rs`:
- Around line 57-58: Document the public QuickCacheBackend::with_capacity
constructor with Rustdoc covering byte-weighted capacity, shard-local admission
limits, and a usage example linking to the relevant QuickCacheBackend types or
methods. Keep the implementation unchanged.

---

Outside diff comments:
In `@rust/lance-core/src/cache/quick.rs`:
- Around line 208-219: Update the fill loop in the eviction test to track
whether any insert_with_key call for a fill-* key was admitted, and assert that
at least one fill entry was cached before the existing size_bytes and size
eviction-bound assertions. Preserve the current overfill scenario and bounds
checks.
- Around line 193-206: The single-flight test currently runs requests
sequentially, so it does not verify concurrent loading. Update the test around
get_or_insert_with_key to spawn multiple tasks using the same uncached key,
synchronize their start, and block the loader with a barrier or notification
until all callers are active; then release it, await all results, and assert
loads remains 1.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: QUIET

Plan: Pro Plus

Run ID: 5b725044-3b5e-4049-9f4a-13cef76d519b

📥 Commits

Reviewing files that changed from the base of the PR and between 81cef54 and 1a96270.

📒 Files selected for processing (1)
  • rust/lance-core/src/cache/quick.rs

Comment thread rust/lance-core/src/cache/quick.rs Outdated
Comment on lines +57 to +58
pub fn with_capacity(capacity: usize) -> Self {
let estimated_items = (capacity / (TARGET_SHARD_SHARE / 32)).max(1);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Document the public constructor.

QuickCacheBackend::with_capacity is public but lacks Rustdoc describing byte-weighted capacity, shard-local admission limits, and usage examples.

As per coding guidelines, **/*.{rs,md,rst} must “Document all public APIs with examples and links to relevant structs and methods.”

🤖 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-core/src/cache/quick.rs` around lines 57 - 58, Document the public
QuickCacheBackend::with_capacity constructor with Rustdoc covering byte-weighted
capacity, shard-local admission limits, and a usage example linking to the
relevant QuickCacheBackend types or methods. Keep the implementation unchanged.

Source: Coding guidelines

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Note

Quiet mode is enabled, so only the most important comments were posted inline. Other review comments are grouped below.

🟡 Other comments (1)
rust/lance-core/src/cache/quick.rs-57-58 (1)

57-58: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the shard-rounding comment.

quick_cache rounds the configured shard count to the next power of two, not “UP to one.” Replace this wording so future maintainers don’t misinterpret the pre-rounding logic.

🤖 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-core/src/cache/quick.rs` around lines 57 - 58, Update the
shard-rounding comment near the quick_cache configuration to state that
quick_cache rounds the configured shard count up to the next power of two,
replacing the incorrect “up to one” wording without changing the implementation.

Sources: Coding guidelines, MCP tools

🤖 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.

Inline comments:
In `@rust/lance-core/src/cache/quick.rs`:
- Around line 69-72: Replace the unconditional 1,000,000 baseline in the cache
initialization logic with an estimate derived from the configured weight budget,
using a bounded explicit average-entry weight and preserving the shard minimum
heuristic. Add regression coverage for small byte-capacity caches to verify the
estimate does not imply excessive resident entries or ghost-key metadata.

---

Other comments:
In `@rust/lance-core/src/cache/quick.rs`:
- Around line 57-58: Update the shard-rounding comment near the quick_cache
configuration to state that quick_cache rounds the configured shard count up to
the next power of two, replacing the incorrect “up to one” wording without
changing the implementation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: QUIET

Plan: Pro Plus

Run ID: 09c21bf1-1d2d-4381-9a94-7491b8792295

📥 Commits

Reviewing files that changed from the base of the PR and between 1a96270 and 1447211.

📒 Files selected for processing (1)
  • rust/lance-core/src/cache/quick.rs

Comment thread rust/lance-core/src/cache/quick.rs
quick_cache splits its weight budget evenly across shards with no
cross-shard borrowing, and silently refuses entries heavier than a
shard's share. Declare estimated items as capacity / (share / 32) so
the library's own shard sizing keeps every shard's share at or above
TARGET_SHARD_SHARE (2 GiB): large caches keep many shards for read
concurrency, small caches trade shards for admissible entry size.
@LuQQiu
LuQQiu force-pushed the lu/quick_cache_backend branch from 1447211 to b0563fb Compare July 23, 2026 20:21

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 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.

Inline comments:
In `@rust/lance-core/src/cache/quick.rs`:
- Line 78: Make QuickCacheBackend::with_capacity propagate the Result from
OptionsBuilder::build() instead of panicking with expect. Update
LanceCache::with_capacity and its callers as needed to propagate or handle
initialization errors while preserving the public cache construction behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: QUIET

Plan: Pro Plus

Run ID: e99002a1-7b33-4ae4-ba16-4ccbc8173d70

📥 Commits

Reviewing files that changed from the base of the PR and between 1447211 and b0563fb.

📒 Files selected for processing (1)
  • rust/lance-core/src/cache/quick.rs

.weight_capacity(capacity as u64)
.shards(shards)
.build()
.expect("quick_cache options");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
curl -fsSL 'https://docs.rs/quick_cache/0.6.0/src/quick_cache/options.rs.html' |
  rg -n 'pub fn build|weight_capacity is not set|estimated_items_capacity is not set'

Repository: lance-format/lance

Length of output: 415


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate file =="
git ls-files | rg '^rust/lance-core/src/cache/quick\.rs$' || true

echo "== file outline =="
ast-grep outline rust/lance-core/src/cache/quick.rs || true

echo "== relevant lines =="
cat -n rust/lance-core/src/cache/quick.rs | sed -n '1,140p'

echo "== dependency/version references =="
rg -n 'quick_cache|OptionsBuilder|build\(\)' rust/Cargo.toml Cargo.lock rust/lance-core -g '*.toml' -g '*.rs'

Repository: lance-format/lance

Length of output: 9469


🌐 Web query:

quick_cache 0.6.0 OptionsBuilder build returns Result missing options source

💡 Result:

In the quick_cache crate (version 0.6.0), the build method on OptionsBuilder returns a Result<Options, Error> because it enforces that specific mandatory fields are set by the user [1]. The implementation of the build method explicitly checks for the following required fields, returning an Error if they are missing [1]: 1. weight_capacity: The builder verifies that this has been provided; otherwise, it returns an error stating "weight_capacity is not set" [1]. 2. estimated_items_capacity: The builder verifies that this has been provided; otherwise, it returns an error stating "estimated_items_capacity is not set" [1]. If both of these fields are configured using their respective methods on the OptionsBuilder, the build call will successfully return an Ok(Options) containing the built configuration [1]. Other fields, such as shards, hot_allocation, and ghost_allocation, are optional and will default to predefined values if not explicitly set [1]. Consequently, users typically call.unwrap on the result of.build in examples when they have ensured these required parameters are provided [1][2][3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== cache backend selection references =="
cat -n rust/lance-core/src/cache/mod.rs | sed -n '160,210p'

echo "== all QuickCacheBackend call sites =="
rg -n 'QuickCacheBackend::with_capacity|with_capacity\(' rust/lance-core/src/cache mod.rs -g '*.rs'

echo "== Result alias and error constructors near top =="
cat -n rust/lance-core/src/lib.rs | sed -n '1,80p'

Repository: lance-format/lance

Length of output: 4136


Make quick cache initialization fallible.

OptionsBuilder::build() returns a Result, but QuickCacheBackend::with_capacity() converts a valid failure to a panic with expect("quick_cache options"). Since LanceCache::with_capacity() also uses this path, changing the constructor would also require changing the public cache constructor or propagating errors explicitly through that boundary.

🤖 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-core/src/cache/quick.rs` at line 78, Make
QuickCacheBackend::with_capacity propagate the Result from
OptionsBuilder::build() instead of panicking with expect. Update
LanceCache::with_capacity and its callers as needed to propagate or handle
initialization errors while preserving the public cache construction behavior.

Source: Coding guidelines

Matches the moka backend's accounting (key footprint + entry bytes) so
the two backends have the same capacity semantics.
@LuQQiu
LuQQiu force-pushed the lu/quick_cache_backend branch from a79504b to c6a63e9 Compare July 23, 2026 20:36

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
rust/lance-core/src/cache/quick.rs (3)

244-245: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Add coverage for prefix invalidation.

This test exercises clear() but never validates the new invalidate_prefix behavior. Add matching and non-matching keys, invalidate the prefix, and assert only the matching entries are removed.

As per coding guidelines, every feature must have corresponding tests.

🤖 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-core/src/cache/quick.rs` around lines 244 - 245, Add coverage in
the cache test around clear() and cache.size() for invalidate_prefix: insert
keys with both matching and non-matching prefixes, call cache.invalidate_prefix
with the target prefix, and assert matching entries are removed while
non-matching entries remain.

Source: Coding guidelines


231-242: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Strengthen the eviction assertion.

size_bytes() <= CAPACITY and size() < 258 also pass if every oversized insertion is rejected. Assert that several fill entries were admitted, then verify the final resident weight/count is below the attempted total.

🤖 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-core/src/cache/quick.rs` around lines 231 - 242, Strengthen the
assertions in the cache fill test by verifying that multiple fill entries were
admitted, rather than allowing all insertions to be rejected. After the
insertion loop, assert a meaningful lower bound on resident entries or weight,
then retain checks that size_bytes() stays within CAPACITY and the final count
remains below the attempted total.

216-229: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make the single-flight test concurrent.

The two calls are sequential, so the second call hits the value already inserted by the first and does not exercise the guard/API’s concurrent single-flight behavior. Start both get_or_insert_with_key calls in parallel before either loader can insert, then assert loads == 1.

🤖 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-core/src/cache/quick.rs` around lines 216 - 229, Make the test
around get_or_insert_with_key concurrent by creating and starting both async
calls before awaiting either result, using the shared loads counter and a
synchronization mechanism if needed to overlap the loaders. Await both results,
verify each returns vec![7u8], and retain the assertion that loads equals 1.
🤖 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.

Outside diff comments:
In `@rust/lance-core/src/cache/quick.rs`:
- Around line 244-245: Add coverage in the cache test around clear() and
cache.size() for invalidate_prefix: insert keys with both matching and
non-matching prefixes, call cache.invalidate_prefix with the target prefix, and
assert matching entries are removed while non-matching entries remain.
- Around line 231-242: Strengthen the assertions in the cache fill test by
verifying that multiple fill entries were admitted, rather than allowing all
insertions to be rejected. After the insertion loop, assert a meaningful lower
bound on resident entries or weight, then retain checks that size_bytes() stays
within CAPACITY and the final count remains below the attempted total.
- Around line 216-229: Make the test around get_or_insert_with_key concurrent by
creating and starting both async calls before awaiting either result, using the
shared loads counter and a synchronization mechanism if needed to overlap the
loaders. Await both results, verify each returns vec![7u8], and retain the
assertion that loads equals 1.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: QUIET

Plan: Pro Plus

Run ID: 948ce55b-aada-4e4e-97b5-481565874521

📥 Commits

Reviewing files that changed from the base of the PR and between b0563fb and a79504b.

📒 Files selected for processing (2)
  • rust/lance-core/src/cache/moka.rs
  • rust/lance-core/src/cache/quick.rs

@LuQQiu

LuQQiu commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

One big tradeoff for quick_cache is shard count .

  • More shards means more parallelism. The default is roughly available_parallelism() * 4, rounded up to a power of two. This reduces lock contention under high concurrent cache access.
  • More shards also means each shard gets a smaller independent capacity budget: roughly total_capacity / shard_count. quick_cache does not appear to support cross-shard capacity borrowing. If shard A is full, it evicts from shard A, even if shard B still has free capacity. This can waste effective capacity when keys are unevenly distributed across shards.
  • It also creates a per-entry admission limit: an entry generally needs to fit within its target shard’s capacity, not just the total cache capacity.

Mini benchmark for picking shard number

Cell value: throughput in Mops = million cache get operations per second
Rows:

  • sN (share) — quick_cache configured with N shards. quick_cache splits the total weight budget evenly, so share = capacity / N is each shard's private budget (shown in parens, e.g. s4 (512M) = 4 shards × 512 MiB each). The share matters because an entry heavier than ~0.97 × share is silently refused admission

Columns (ratio = cache capacity ÷ working-set size — how much of the data fits):

1 worker x 2 GiB (5s)

shards (share) 0.1 0.5 1.2 2.0
s1 (2G) 17.05 32.13 39.65 39.68
s2 (1G) 17.06 32.13 36.94 39.66
s4 (512M) 16.48 30.81 36.20 39.65
s8 (256M) WARN-churn 15.87 29.19 34.15 36.76
s16 (128M) WARN 36.26 36.04 35.73 35.28
moka 3.09 5.17 5.83 5.89

1 worker x 4 GiB (5s)

shards (share) 0.1 0.5 1.2 2.0
s1 (4G) 15.74 31.74 39.68 39.67
s2 (2G) 16.74 31.84 38.52 39.67
s4 (1G) 16.80 32.37 39.16 39.65
s8 (512M) 15.81 29.28 37.07 39.10
s16 (256M) WARN-churn 15.94 28.59 34.54 38.55
moka 3.33 4.34 5.73 5.78

4 workers x 8 GiB (s1-s16: 5s)

shards (share) 0.1 0.5 1.2 2.0
s1 (8G) 1.07 3.86 12.98 12.06
s2 (4G) 1.31 4.28 9.19 10.02
s4 (2G) 1.54 4.57 12.59 12.00
s8 (1G) 2.21 5.99 12.77 13.39
s16 (512M) 3.05 6.70 15.68 15.80
s64 (128M) WARN 31.4 29.2 25.4 22.5
s256 (32M) WARN 35.6 31.5 25.7 24.3
s1024 (8M) WARN 30.3 31.0 27.2 24.3
moka 2.01 1.89 2.12 2.11

4 workers x 16 GiB (s1-s16: 5s)

shards (share) 0.1 0.5 1.2 2.0
s1 (16G) 1.08 3.69 14.04 13.05
s2 (8G) 1.26 4.06 11.45 10.81
s4 (4G) 1.63 5.05 11.42 12.62
s8 (2G) 2.17 6.54 13.82 16.67
s16 (1G) 3.15 8.06 16.62 20.30
s64 (256M) WARN-churn 5.88 11.58 16.92 21.05
s256 (64M) WARN 36.9 34.6 31.2 28.5
s1024 (16M) WARN 35.3 32.3 29.5 29.0
moka 1.69 1.83 2.14 2.15

32 workers x 64 GiB

shards (share) 0.1 0.5 1.2 2.0
s1 (64G) 0.35 1.10 11.3 11.1
s16 (4G) 4.90 9.13 26.0 32.4
s64 (1G) 11.7 23.3 39.1 68.2
s256 (256M) WARN-churn 26.0 43.9 56.4 55.8
s1024 (64M) WARN 228.4 203.6 175.9 154.6
moka 1.54 1.81 1.00 0.96

32 workers x 128 GiB

shards (share) 0.1 0.5 1.2 2.0
s1 (128G) 0.35 1.60 11.9 11.0
s16 (8G) 5.00 9.62 28.3 36.4
s64 (2G) 11.5 25.5 55.2 96.5
s256 (512M) 26.2 55.4 86.8 116.8
s1024 (128M) WARN 228.9 228.4 205.4 186.9
moka 1.95 1.15 1.11 1.19

128 workers x 256 GiB

shards (share) 0.1 0.5 1.2 2.0
s1 (256G) 0.23 0.55 16.8 16.8
s16 (16G) 3.27 7.41 39.6 37.9
s64 (4G) 7.39 16.1 46.1 120.2
s256 (1G) 17.8 37.5 94.4 119.4
s1024 (256M) WARN-churn 65.5 110.4 124.6 51.5
moka 1.75 4.69 1.14 0.90

128 workers x 512 GiB

shards (share) 0.1 0.5 1.2 2.0
s1 (512G) 0.23 0.53 15.2 13.4
s16 (32G) 3.26 6.16 36.0 34.8
s64 (8G) 7.19 14.8 48.1 116.9
s256 (2G) 17.3 35.8 125.0 185.1
s1024 (512M) 53.2 107.2 181.9 316.1
moka 1.22 0.93 0.60 0.64

320 workers x 640 GiB

shards (share) 0.1 0.5 1.2 2.0
s1 (640G) 0.23 0.34 16.6 16.0
s16 (40G) 2.10 3.06 38.2 36.0
s64 (10G) 3.77 7.55 27.8 103.3
s256 (2.5G) 9.30 18.6 60.9 101.9
s1024 (640M) 26.2 48.9 99.0 173.5
moka 0.78 0.56 0.61 0.60

320 workers x 1280 GiB (s128-s1024: 5s, with power-of-two rounding proof)

requested->actual (share) 0.1 0.5 1.2 2.0
s1 (1280G, 3s) 0.22 0.35 14.6 16.3
s16 (80G, 3s) 2.10 3.11 39.0 39.7
s128->128 (10G) 5.70 11.1 50.3 193.5
s256->256 (5G) 8.64 17.7 80.0 334.4
s320->512 (2.5G) 14.2 30.0 95.1 217.3
s384->512 (2.5G) 14.1 29.6 94.6 523.0
s512->512 (2.5G) 13.6 29.8 95.6 521.6
s768->1024 (1.25G) 25.3 50.9 123.8 328.9
s1024->1024 (1.25G) 24.2 48.6 116.7 248.9
moka (5s) 0.24 0.20 0.21 0.21

Best shard number in my mind

CPU = 1, shard = 1
CPU = 4, shard = 1-2
CPU = 32, shard = 16
CPU = 128, shard = 64
CPU = 320, shard = 128
Enough performance (> 3Mops required by FTS benchmark, better to be > 5Mops), each shard is big enough to give more balanced cache load, better cache utilization and reduce the chance to reject/evict big objects)

Algorithm

shards = clamp(round_down_pow2(min(cpus / 2, capacity / 4 GiB)), 1, 1024)

What the formula yields

Config (CPU x cache) CPU/2 cap/4GiB shards share
1 x 2-4 GiB 1 1 1 2-4 G
4 x 8-16 GiB 2 2-4 2 4-8 G
32 x 64-128 GiB 16 16-32 16 4-8 G
128 x 256-512 GiB 64 64-128 64 4-8 G
320 x 640-1280 GiB 128 128-256 128 5-10 G
Our lance node (320 x 960 GiB) 128 128 128 7.5 G

Wasted cache space when the working set does NOT fit (chosen shard counts)

Waste = 100% − utilization: capacity the cache failed to fill while under eviction pressure (working set 10× / 2× larger than the cache). moka's global pool wastes ~0%; this is the entire space cost of sharding.

  │ Config (chosen shards) │ Waste @ ratio 0.1 │ Waste @ ratio 0.5 │
  ├────────────────────────┼───────────────────┼───────────────────┤
  │ 1 × 2 GiB (s1)         │ 4.0%              │ 5.6%              │
  ├────────────────────────┼───────────────────┼───────────────────┤
  │ 1 × 4 GiB (s1)         │ 2.4%              │ 0.1%              │
  ├────────────────────────┼───────────────────┼───────────────────┤
  │ 4 × 8 GiB (s2)         │ 2.6%              │ 2.6%              │
  ├────────────────────────┼───────────────────┼───────────────────┤
  │ 4 × 16 GiB (s2)        │ 1.0%              │ 0.3%              │
  ├────────────────────────┼───────────────────┼───────────────────┤
  │ 32 × 64 GiB (s16)      │ 1.6%              │ 1.3%              │
  ├────────────────────────┼───────────────────┼───────────────────┤
  │ 32 × 128 GiB (s16)     │ 0.6%              │ 0.9%              │
  ├────────────────────────┼───────────────────┼───────────────────┤
  │ 128 × 256 GiB (s64)    │ 1.2%              │ 1.7%              │
  ├────────────────────────┼───────────────────┼───────────────────┤
  │ 128 × 512 GiB (s64)    │ 0.8%              │ 1.0%              │
  ├────────────────────────┼───────────────────┼───────────────────┤
  │ 320 × 1280 GiB (s128)  │ 0.7%              │ 0.7%              │
  └────────────────────────┴───────────────────┴───────────────────┘

waste ratio is not that big, acceptable

@LuQQiu

LuQQiu commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

End-to-end FTS shard sweep (real 100M-doc 42-language v3 index, 960 GiB cache, prewarmed, c128, 90s/arm)
When everything is cached, even shard = 1 can generate max performance.

  ┌──────────────────────────────────┬────────┬──────────────┐
  │           quick shards           │  QPS   │ Mean latency │
  ├──────────────────────────────────┼────────┼──────────────┤
  │ default (formula → 128)          │ 1247.6 │ 102.6 ms     │
  ├──────────────────────────────────┼────────┼──────────────┤
  │ 1                                │ 1323.2 │ 96.8 ms      │
  ├──────────────────────────────────┼────────┼──────────────┤
  │ 16                               │ 1251.7 │ 102.3 ms     │
  ├──────────────────────────────────┼────────┼──────────────┤
  │ 64                               │ 1255.5 │ 102.0 ms     │
  ├──────────────────────────────────┼────────┼──────────────┤
  │ 128 (explicit)                   │ 1178.2 │ 108.6 ms     │
  ├──────────────────────────────────┼────────┼──────────────┤
  │ 512                              │ 1255.9 │ 101.9 ms     │
  ├──────────────────────────────────┼────────┼──────────────┤
  │ 2048 (library default)           │ 1255.7 │ 101.9 ms     │
  ├──────────────────────────────────┼────────┼──────────────┤
  │ — moka baseline, same scenario — │ ~180   │ —            │
  └──────────────────────────────────┴────────┴──────────────┘

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

Labels

A-deps Dependency updates performance

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant