Skip to content

perf: interior-mutable S3-FIFO freq bump so GET is a shared read (#576 COW prerequisite)#587

Merged
ELares merged 1 commit into
mainfrom
perf/576d-interior-freq
Jul 9, 2026
Merged

perf: interior-mutable S3-FIFO freq bump so GET is a shared read (#576 COW prerequisite)#587
ELares merged 1 commit into
mainfrom
perf/576d-interior-freq

Conversation

@ELares

@ELares ELares commented Jul 9, 2026

Copy link
Copy Markdown
Owner

What

The prerequisite for the #576 per-slot Arc-COW snapshot fix. A de-risk microbench proved Arc-COW regresses GET ~11% ONLY because IronCache's GET bumps the S3-FIFO frequency via find_mut -- which, with the table behind an Arc, would force Arc::get_mut's atomic refcount check on every GET. Making the freq bump INTERIOR-MUTABLE (so a GET hit is a SHARED read) drops that to ~1-2% (under the gate). This does that change ON ITS OWN, small + independently measured.

Entry::bump_freq_shared(&self) read-modify-writes the freq through the entry's own raw pointer (Str: the flags byte at STR_BLOB_OFFSET + FLAGS_OFFSET; Coll: CollEntry.eviction_rank). The GET read + read-only rmw paths went from slot_table_mut().find_mut().bump_freq() to slot_table().find().bump_freq_shared(); the mutating bump_freq(&mut self) stays for paths that already hold &mut (eviction second-chance, dec, rmw_mut).

Safety (documented in the accessor): the freq lives INSIDE the pointee, a separate allocation from the Entry's own pointer word, so the &self borrow (which covers only the pointer word) does not forbid mutating the pointee; the raw pointer carries write provenance and no overlapping &/&mut view is formed. Non-atomic is sound because a shard's entries are owned by exactly one core and never sent across threads (ADR-0002/0005; the store is !Send/!Sync). The freq is a bounded 0..=3 counter, not a clock (ADR-0003 holds).

Tests

Eviction is BYTE-IDENTICAL: all 17 eviction integration tests pass unchanged (incl. evict_to_fit_is_deterministic_on_replay, the pool-eviction determinism + hot-key-survival tests) + 2 new unit tests asserting the shared bump equals the mutable bump across Str / Str+TTL / Coll arms + a test bumping freq through a plain &Entry. 1156 server+ironcache tests pass. GET microbench delta [+0.21% +0.70% +1.23%] (criterion: within noise). clippy -D warnings + io_uring build + invariant + prior-art lints + fmt clean; no dashes.

Incidental cleanup: the read-only rmw path previously did a double probe (find_mut to bump + a separate find) and allocated slot tables; it collapsed to a single find with no allocation.

Part of #576 (per-slot Arc-COW prerequisite).

🤖 Generated with Claude Code

… read (#576 prereq)

The GET read path used find_mut + Entry::bump_freq, taking a unique &mut on the slot
table and the entry just to bump the S3-FIFO 2-bit promote frequency. That &mut is the
measured driver of the per-slot Arc-COW regression: with the value behind an Arc, a
freq bump on GET forces Arc::get_mut's atomic strong/weak refcount check on the hot
path. A de-risk microbench put that at roughly 11% on GET; making the freq bump a
SHARED read drops it under the gate to roughly 1 to 2%. This PR makes only the
freq-bump decoupling, with no Arc and no COW yet, so it is small, independently
reviewable, and independently measured.

The change adds Entry::bump_freq_shared(&self), an interior-mutable freq bump, and
switches the read (GET) and read-only rmw (e.g. GETEX) paths from
slot_table_mut().find_mut().bump_freq() to slot_table().find().bump_freq_shared(). The
whole hit path is now a shared borrow of the table and the entry; the mutating
bump_freq(&mut self) stays for the paths that already hold &mut (rmw_mut, eviction
second-chance, dec).

Soundness (single-threaded per shard, ADR-0002/0005): Entry is an 8-byte tagged
NonNull<u8> that solely owns its pointee (a Str blob or a Box<CollEntry>). The freq
lives INSIDE that pointee, a separate allocation from the Entry's own pointer word, so
the &self borrow, which covers only the pointer word, does not forbid mutating the
pointee. bump_freq_shared loads the owned raw pointer (raw pointers are Copy and carry
their allocation's write provenance) and patches a single byte through it, forming no
overlapping shared/mutable view of that byte for the write. There is no cross-thread
aliasing: a shard's Entrys are owned by exactly one core and never sent across threads
(the store is !Send/!Sync), so a plain non-atomic write is the correct primitive; an
atomic would be pure overhead. The freq is a bounded 0..=3 counter, not a clock or
entropy source, so this stays deterministic (ADR-0003).

Eviction is BYTE-UNCHANGED: bump_freq_shared computes the same (freq + 1).min(3) and
writes the same bits as bump_freq, for both the Str (flags bits 1-2) and Coll
(eviction_rank) arms. The full ironcache-store + ironcache-server + ironcache suites
pass unchanged, including every eviction integration test (evict_to_fit determinism on
replay, lowest-freq pool eviction, hot-key survival, pool carryover determinism), plus
two new unit tests asserting the shared bump is byte-identical to the mutable bump
through a &Entry across a full access sweep.

Measured GET delta (criterion store/read_hit_embstr, 10k embstr keys, mid-table hot
key, Docker rust:1-bookworm): median 27.382 ns before, 27.453 ns after; change
[+0.21% +0.70% +1.23%], which criterion reports as within the noise threshold.
Neutral as expected, since this only changes HOW the freq is bumped (a raw-pointer
byte write vs a &mut slice write), adding no indirection.

Part of #576 (per-slot Arc-COW prerequisite).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Zeke <ezequiel.lares@outlook.com>
@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown

perf-gate (A5)

Same-runner ratchet of HEAD against the merge-base (both rebuilt and measured in this job).
PASS = within the noise band, WARN = a real move inside budget (does not fail), FAIL = past budget in the bad direction.

metric base head delta% band budget verdict
qps_median (peak) 96106.50 96515.93 0.43% +/-5.00% drop <= 15% PASS
bytes_per_key int 45.10 45.10 0.00% det rise <= 5% PASS
bytes_per_key embstr 60.13 60.13 0.00% det rise <= 5% PASS
bytes_per_key raw 332.43 332.43 0.00% det rise <= 5% PASS

Overall: PASS

  • qps: noisy on shared CI, so the band comes from the base reps spread (floored at 5%); a drop is only a regression past the 15% budget.
  • bytes_per_key: deterministic (allocator-true memmodel), so a tight 5% rise budget; any rise beyond it FAILs.
  • Open-loop tails / criterion micro-benches are reported-not-failed (tail noise is high) and are not part of this ratchet.
  • An intentional perf trade is landed by raising the relevant budget in this PR with a documented reason (CI never auto-commits a baseline).

@ELares
ELares merged commit 7b49cba into main Jul 9, 2026
16 checks passed
ELares added a commit that referenced this pull request Jul 9, 2026
… frozen shard off-thread (#576) (#588)

The concurrent-snapshot p99.9 catastrophe (3.5s vs a ~40ms baseline on c7g) was
NOT the encode+fsync (#586 already moved those off-thread) but the O(N)
`snapshot_chunk` COPY of the whole keyspace into owned records, which HAD to stay
on the serving core (the store is `!Send`) and contended the datapath on memory
bandwidth. #571 (yield) only interleaved it and #578 (throttle) only STRETCHED it
over a longer window, both measured to help nothing / hurt.

This removes the serving-side copy entirely via per-slot Arc copy-on-write:

- STORE: `dbs` is now `Vec<Vec<Arc<HashTable<Entry>>>>`. GET reads through the Arc
  as a SHARED deref (`slot_table`) -- one pointer indirection, NO `Arc::get_mut`,
  NO atomic on the read path (keeps GET at +1-2% with #587's shared freq bump). A
  write takes `Arc::make_mut` (`slot_table_mut`): the common not-saving case is
  strong_count==1, an uncontended in-place mutate; while a save holds a frozen
  clone (strong_count>1) the first write to a slot deep-clones it.

- DEEP-CLONE-ON-COW (the correctness crux): `Arc::make_mut` on a frozen slot clones
  `HashTable<Entry>`, which clones each `Entry`, and `Entry`'s `Clone` is a DEEP
  clone (a fresh pointee allocation -- Str blob copied, CollEntry re-boxed). The
  fresh table becomes the live slot; the frozen Arc retains the ORIGINAL entries,
  sole-owned by the persist thread. So a write during a save is NEVER visible in
  the dump and NEVER mutates/frees a pointee the persist thread reads: no data
  race, no use-after-free, no double-free. A shallow (pointer-copy) clone would be
  catastrophic here; `Entry` is verified NOT `Clone`-shallow.

- FrozenSlot unsafe Send: `Arc<HashTable<Entry>>` is `!Send` (Entry is a `!Send`
  tagged NonNull). `struct FrozenSlot(Arc<..>)` with `unsafe impl Send` hands one
  slot to the persist thread. SOUND because a frozen slot is de-facto IMMUTABLE for
  the save: every datapath write COWs first (above), and the one shared-`&Entry`
  write -- the S3-FIFO freq bump (`bump_freq_shared` on GET/rmw) -- is GATED OFF via
  a per-shard `saving` flag for the save window, so no byte the persist thread reads
  is mutated. The Arc refcount is atomic, so the cross-thread move/drop is
  race-free; dropping a FrozenSlot frees pointees only when strong_count hit 1 (the
  slot was COW'd away, no live reader remains). This mirrors why the store is
  otherwise !Send (mutable aliasing on the owning core), which a frozen slot lacks.

- SAVING FLAG fast path: a plain `bool` (single-threaded shard, ADR-0002/0005;
  the persist thread never touches it). `begin_save` Arc-clones every non-empty
  slot (O(slots), no key copy) and sets the flag; `end_save` clears it. The
  datapath fast path (not saving) is one predictable branch + the Arc deref.

- SAVE PATH (coordinator `save_shard_local`): FREEZE via `begin_save`, hand the
  `Vec<FrozenSlot>` to the dedicated `ic-persist-<n>` thread (#586 pattern), which
  runs the whole O(N) encode+fsync (`dump_frozen_slots` -> `to_kvobj` + encode +
  CRC + `write_shard_dump`) OFF the serving core, drops the frozen Arcs, and signals
  a oneshot. The shard AWAITS that (a cross-thread wake, not a blocking join) and
  serves throughout. `end_save` runs on the normal-completion path only (all arms
  mean the persist thread's closure has fully exited); on task cancellation the flag
  safely stays set on an exiting process (never racing the still-running thread).

- CRASH-SAFETY preserved (#530): the persist thread writes only the per-shard file
  atomically; the manifest is still written LAST on the home core. A panicked
  persist thread -> a failed save (record_save_failed), never a hang; frozen Arcs
  dropped on unwind.

- CONSISTENCY: the dump is now a per-shard POINT-IN-TIME as of the freeze (STRONGER
  than the #571 chunked walk -- a mid-save write keeps its pre-freeze value in the
  dump, or is omitted if newly created). Cross-shard stays fuzzy (each shard freezes
  at its own instant), the accepted cache warm-start tradeoff. The #578 throttle
  knob stays settable but is inert for the tail (the copy it throttled is gone).

TESTS (this is the correctness-critical PR):
- store `cow_deep_clone_isolates_frozen_slots_from_overwrite_and_delete`: overwrite +
  delete every frozen key on the LIVE store while holding the FrozenSlots; the frozen
  view keeps its pre-freeze values byte-for-byte, the live store advances, and drop
  ordering is UAF/double-free-free.
- store `freq_bump_skipped_while_saving_then_resumes`: the soundness gate.
- store `point_in_time_holds_under_mass_writes_at_scale` (200k keys).
- persist `dump_frozen_slots_is_point_in_time_across_later_writes`: dump + RELOAD
  shows the frozen point-in-time; post-freeze overwrites/deletes never corrupt it.
- integration `bgsave_cow_serves_inplace_writes_and_reloads_consistently`: real
  APPEND/INCR/DEL/SET during a BGSAVE, each serviced with low latency, reload
  self-consistent, live writes present.
- MIRI: `cargo +nightly miri test` ran BOTH focused store tests clean (no Undefined
  Behavior / UAF / double-free), including the deep-clone-on-COW + FrozenSlot drop
  ordering crux (91s).
- clippy -D warnings clean, fmt clean, CI invariants + prior-art pass, io_uring
  build ok; existing bgsave/off-thread/backpressure/crash/torn/turnkey_restart tests
  all pass.

Part of #576.


Claude-Session: https://claude.ai/code/session_01VFWCZR47TDvsFPJ1waX85X

Signed-off-by: Zeke <ezequiel.lares@outlook.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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