Skip to content

perf: in-memory and sharded store speedups; promote on cache_set overwrite - #299

Open
jaemk wants to merge 9 commits into
masterfrom
260726.perf-baseline
Open

perf: in-memory and sharded store speedups; promote on cache_set overwrite#299
jaemk wants to merge 9 commits into
masterfrom
260726.perf-baseline

Conversation

@jaemk

@jaemk jaemk commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Performance work across the in-memory and sharded stores, plus a behavior change to cache_set recency.

Breaking

  • cache_set (and cache_set_returning_entry) over an existing key now promote that key to most-recently-used instead of replacing it in place. Affects LruCache, LruTtlCache, ExpiringLruCache, ShardedLruCache, ShardedLruTtlCache, and ShardedExpiringLruCache, and changes which entry a capacity eviction selects in overwrite-heavy workloads. cache_peek / cache_peek_with_expiry_status / cache_contains stay non-promoting. See specs/design/0038-cache-set-promotes-on-overwrite.md.
  • Resolves an on_evict divergence on the sharded LRU-TTL and expiring-LRU stores: the callback and no-callback write branches now share one promoting primitive, so configuring an observational on_evict no longer changes eviction order.

Changed

  • Sharded stores resolve a read hit in one hash lookup instead of two, count evictions per shard instead of through a shared striped counter, and cache the host CPU topology instead of probing it per construction.
  • LRU-family and expiry-aware stores sweep in one pass instead of collecting keys first; TTL stores sample the clock once per operation instead of once per entry examined.
  • ExpiringLruCache::cache_set fires on_evict with the stored key, matching LruTtlCache, and drops an unconditional key clone from every set.
  • TtlSortedCache reuses the stored key on overwrite, its get_or_set family no longer removes an expired entry before running the initializer, and retain_latest counts an eviction before firing on_evict.

Tests

  • New certification tests for promotion and the eviction-victim shift, head and sole-entry move_to_front ring integrity, sharded semantics and concurrency, stored-key lifecycle, and the LRU-TTL / expiring-LRU parity.

Redis tests require a live server (CACHED_REDIS_CONNECTION_STRING); CI runs --all-features.

jaemk added 9 commits July 26, 2026 10:25
…ed per-op paths

Adds criterion coverage for the paths that were unmeasured: O(n) sweeps
(`evict`/`retain`/`retain_latest`/`key_order`/`iter_order`/`iter`) at N=10k,
the `refresh_on_hit` hit path, `cache_set` over a live key with and without
`on_evict`, whole-cache `clear`/`len`/`metrics`, single-threaded `cache_get`
for all six sharded stores, an 8-thread expiry storm where every read is a
lazy eviction, an 8-thread `len`/`metrics` poll, sharded `build()` time, a
large-value LRU group, and `Instant::now()` itself.

Sweep and clear benchmarks use `iter_batched` so each sample rebuilds a
populated cache and measures the sweep rather than an already-swept one.
Every entry-removing op gets a `String`-key variant alongside the `usize`
one, since key-clone and re-hash costs are invisible with `Copy` keys.
`TtlCache` and `TtlSortedCache` read the clock twice on several paths: once to
test liveness and again to recompute an expiry or judge a displaced entry. On
this machine `Instant::now()` costs ~19ns against a ~9ns hash-and-probe, so the
redundant read dominated. Both stores now sample once per operation and thread
it through, including the async get-or-set families. `retain` samples once per
pass rather than once per entry, matching `evict`, which already did.

`LruCache` sweeps cloned every doomed key and then re-hashed it to remove the
entry. `LRUList` gains `iter_indices` and `drain_into`, and `LruCache` gains
`remove_index`, `drain_all`, and `pop_raw_with_hash`, so `retain`,
`retain_silent`, and `cache_clear_with_on_evict` work from slot indices instead.
`cache_clear_with_on_evict` no longer hashes at all. The four order methods
pre-size their `Vec` from the live count.

`TtlSortedCache::evict` detached the expired prefix with a counted range scan
followed by one `pop_first` per entry. It now uses a single `split_off` and
drains the detached tree by value, with no rebalancing. `set_inner` returns the
stamp it indexed so `set_and_get_mut` needs neither a second key clone nor a
re-lookup, and the occupied branch reuses the stored key `Arc`.

`StripedCounter` gains `increment_mut` for the `&mut self` read paths, which
skips the thread-local lookup and the atomic RMW that shared access requires.

`ExpiringCache` wrapped an `UnboundCache` it bypassed for every read and write,
carrying two `StripedCounter`s it never incremented. It now holds its `HashMap`
directly.

Sharded stores cache the default shard count, which parses several /proc files
per build, and drop the read guard before bumping hit/miss counters.

Measured against a pristine build: `UnboundCache` hit -44%, `LruCache`
`cache_clear_with_on_evict` 2.67x, `TtlSortedCache::evict` 1.70x, `TtlCache`
overwrite -32 to -38%, `TtlCache` refresh-on-hit -30 to -35%, `retain` on String
keys -35%.

Behavior changes, all making a variant agree with its siblings:

- The `get_or_set` family on `TtlSortedCache` no longer sweeps an expired entry
  before running the factory. A cancelled or panicking factory now leaves the
  entry in place and fires no `on_evict`, matching the sync variants,
  `TtlCache`, and `LruTtlCache`. On success the callback fires after the factory
  rather than before.
- For a key type whose `Eq` ignores part of its payload, `TtlSortedCache`
  reports the first-inserted key to `on_evict` and `cache_remove_entry` rather
  than the most recent, matching `HashMap::insert`.
- `TtlSortedCache::retain_latest` counts an eviction before firing `on_evict`,
  so a panicking callback no longer removes an entry without counting it.
…ne-pass sweeps

`ShardedLruTtlCache::cache_get` and `ShardedExpiringLruCache::cache_get` did two
full hash-and-probe lookups per read hit, both inside the exclusive write lock:
`cache_peek` to test liveness, then `cache_get` to fetch. They now use
`get_if`, falling through to `pop_raw` only on a miss to tell expired from
absent, the shape `cache_get_with_expiry_status` already used. Combined with
hoisting the clock read out of the critical section, four-thread read throughput
goes up 84% on `ShardedLruTtlCache` and 16% on `ShardedExpiringLruCache`.

The expiry-capable sharded stores each kept one process-wide eviction counter in
their `Arc<Inner>`, contended from every core and sharing a cache line with
`shards`, `shard_mask`, `ttl_nanos` and `refresh`, all read on every operation.
Eviction counting moves to a per-shard counter alongside the existing hit and
miss counters, summed on read. Capacity and non-capacity evictions stay
disjoint.

`evict` on the map-backed sharded stores cloned every expired key into a `Vec`
and then re-hashed each one to remove it. It is now a single `extract_if` pass,
with a `retain` plus length delta when no callback is set. That is 3.2x on
`ShardedTtlCache` and 3.0x on `ShardedExpiringCache` with `String` keys.

`cache_clear_with_on_evict` across the LRU family walks the recency chain with
`drain_all` instead of cloning every key and re-hashing it: 2.4x.

`available_parallelism` parses several /proc files and ran on every sharded
cache construction. Caching it drops `build()` by 92 to 99%.

`ExpiringLruCache::cache_set` cloned the key on every insert whenever `on_evict`
was set, though the clone was only used on the rare expired-displacement branch.
It now takes the displaced entry from the store instead.

Behavior changes:

- `ExpiringLruCache::cache_set` fires `on_evict` with the stored key rather than
  the caller's key, matching `LruTtlCache`. Observable for key types whose `Eq`
  ignores part of the payload.
- The sharded TTL and LRU-TTL stores decide expiry against the caller's own
  clock sample, taken before the shard lock is acquired. Under contention an
  entry that crosses its expiry while the caller queues is served as live. This
  stays inside the documented lazy-expiry contract, which never promised prompt
  removal.

Also records the intended `max_size`-bounded default shard count as a design
note; the builders are not wired to it yet.
…motion

Assert both link directions after promoting an already-head entry and the
only entry, via a new `order_reversed` helper that walks the `prev` chain.
A ring whose `prev`/`next` links disagree (e.g. a `move_to_front` that skips
`unlink`) corrupts into a cycle that never reaches the `OCCUPIED` sentinel,
which turns forward iteration into an unbounded loop; these assertions catch
that before it can happen.
`cache_set` and `cache_set_returning_entry` over an existing key now move
that entry to most-recently-used instead of replacing it in place. A write
counts as an access, so an overwrite behaves like a fresh insertion. This
applies to `LruCache`, `LruTtlCache`, `ExpiringLruCache`, `ShardedLruCache`,
`ShardedLruTtlCache`, and `ShardedExpiringLruCache`, and it changes which
entry a capacity eviction selects in overwrite-heavy workloads: the
overwritten key survives longer and another key becomes the victim.

The sharded LRU-TTL and expiring-LRU stores previously promoted only on
their `on_evict` branch (via a `get_or_set_with_if` helper) and not on the
no-callback branch, so attaching a purely observational `on_evict` changed
eviction order. Both branches now go through the promoting primitive and the
helper is removed; the divergence is gone.

`cache_peek`, `cache_peek_with_expiry_status`, and `cache_contains` remain
non-promoting, so a write and a peek stay distinguishable. Inserting a new
key still goes to the front via `push_front` and is unchanged.

Add `specs/design/0038`, the LRU-7 behavior statement in store-lru.md, and
certification tests covering promotion, the eviction-victim shift, head and
sole-entry `move_to_front`, and sharded semantics/concurrency.
3.4.0 emits plain `rust` code fences and drops the hidden `# pub fn main`
lines that 3.3.3 kept. CI installs the latest cargo-readme, so `check/readme`
failed against the 3.3.3-generated file. No source doc comments changed.
…deflaked tests

- `ttl_sorted::evict_at` no-callback branch: drain the expired prefix into a
  Vec and count evictions per removal so a value `Drop` panic leaves `map` and
  `keys` in lockstep instead of orphaning rows
- narrow the before-lock clock-sample contract in `specs/store-sharded.md` to
  the TTL-family stores; the `Expires`-family decides expiry under the write
  lock; add `ShardedLruTtlCache` to the matching CHANGELOG entry
- mark the spec 0037 default shard cap as planned, not yet wired, in the spec
  and design record; fix the `default_shard_count_for_capacity` count-vs-capacity doc
- document promote-on-overwrite on the `Cached::cache_set` trait doc; drop
  `cache_clear_with_on_evict` from the per-shard eviction-counter docs and the
  stale global-counter bench comment
- pre-size `doomed_indices` in `lru::retain`
- raise TTLs in the refresh-on-hit perf and sharded-semantics tests, make the
  sharded flip-stress test assert real evictions, and cover the factory-error
  miss count for `cache_try_get_or_set_with_mut`
… zero-margin peek-refresh test

- `ConcurrentCached::cache_set`: a displaced expired entry is filtered to `None` and delivered to
  `on_evict`, not returned, so a returned `Some` is always live; add the promote-on-overwrite note.
- `store-expiring.md` EXPIRE-7 and `store-lru.md` LRU-6: `on_evict` fires on an overwrite only when
  the displaced entry has already expired. A plain `LruCache` overwrite returns the old value and
  fires no callback.
- `TtlSortedCache::set` doc: size-limit trimming runs on every `set`, independent of the `.evict()`
  expiry-sweep opt-in.
- `peek_and_contains_do_not_refresh_the_expiry`: shorten the final sleep so the read lands between
  the original and refreshed deadlines, so a peek that wrongly refreshes expiry now fails the test.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant