You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Design spike: close the during-snapshot p99.9 (291ms -> ms-class) via per-key incremental delta snapshots
Owner go/no-go. Recommendation: GO Phase 0 + Phase 1; gate Phase 2/3 on a measurement.
1. Root-cause verdict (CONFIRMED)
The 291ms during-snapshot p99.9 is a fundamental shared memory-bandwidth floor, not a scheduling bug. The ic-persist-<shard> thread reads EVERY frozen entry once, deep-clones it (to_kvobj, store/src/kvobj.rs:2912), and encodes it (encode_kvobj, repl/src/kvcodec.rs:156) into the body Vec. On an all-cores-busy thread-per-core datapath (ADR-0002) this full-keyspace read saturates the memory controller and lengthens every concurrent op's tail. The write+fsync is once-per-shard and negligible.
Proof it is bandwidth, not CPU/scheduling — the five failed levers (all c7g):
This corrects the "just yield like Dragonfly PR #4910" framing: that fixes CPU starvation; IronCache already tried the yield family and it does nothing here. The only lever that moves a bandwidth floor is reading fewer bytes.
Phase 0 must include a c7g perf stat DRAM-bandwidth/LLC-miss capture to confirm this inference directly (it's currently derived from the pin/throttle A/Bs, not measured).
2. Recommended lever
Incremental (dirty-tracked delta) snapshots at PER-KEY granularity. Only this reduces the persist READ footprint.
NUMA/non-temporal: ~10-40% of residual, cheap, doesn't remove the floor — fold into Phase 0.
Cheap win the 5 attempts missed: each entry is copied TWICE (to_kvobj then encode_kvobj). Encode directly from the frozen Entry blob for the string-family common case => ~half the persist-thread memory traffic. Ship in Phase 0.
CRITICAL correction to the research: the proposed per-SLOT dirty bit does NOT work. DEFAULT_SLOTS_PER_DB = 256 and slot_index = scan_hash(key) & (slots-1) spread keys uniformly across 256 slots; at 10% SET / zipf essentially ALL 256 slots go dirty per window => ~0% read reduction. Granularity must be per-key (per-shard dirty-key set) or per-bucket.
3. Design
Hook: add a per-shard dirty-key set in the single write funnel slot_table_mut (store/src/lib.rs:786), mirroring the reserved OnWrite attach point (touch_watch, lib.rs:800). Single-writer per shard => no atomics.
Delta content:ShardDumpBuilder (persist/src/lib.rs:261) skips entries whose key isn't in the dirty set captured at the epoch cut; emits TOMBSTONEs for deletes. Base = full dump + clear set (inside the saving window). Delta = dirty keys + tombstones.
Format (FORMAT_VERSION 1 -> 2): keep MAGIC=ICSS, per-shard .icss + dump.manifest as sole commit point (tmp->fsync->rename->dir-fsync). Manifest gains a per-shard CHAIN {base, [deltas], base_epoch, chain_epochs}; each delta is self-describing (base_epoch, own epoch, record type {PUT,TOMBSTONE}, body CRC32). v1 fail-closes on v2 (harden: format-version-mismatched snapshots must fail loudly, not boot silently empty #530).
Warm-start: load base, replay deltas in manifest order (later wins; TOMBSTONE removes). Deterministic (ADR-0003: manifest-order = fixed integer sequence, no clock/RNG).
Crash-safety: manifest-last stays the ONLY commit point; crash mid-delta loads the last committed prefix; torn delta caught by CRC => truncate chain to last good link. Never reference a delta whose file+CRC wasn't fsync'd first. Preserve the unsafe impl Send for FrozenSlot contract and the saving-gated freq-bump.
Compaction:max_deltas_per_base (default ~8, tunable); on overflow the next save is a full base and the chain resets. Bounds warm-start replay + disk.
forbid-unsafe preserved: dirty-set lives in ironcache-store; persist/repl stay #![forbid(unsafe_code)].
4. Effort / risk / phasing — XL, decomposed
Phase 0 (Small, ship now, GO-independent): perf-counter confirm; halve the double deep-copy; MADV_SEQUENTIAL/non-temporal reads. Likely 291ms -> ~150-180ms, near-zero risk, no format bump.
Phase 1 (Medium, INERT/default-off): per-key dirty set behind a flag; v2 delta format + manifest chaining; produce deltas but loader still base-only. Ships dark; measures dirty-key-bytes / total-bytes = the go/no-go for Phase 2.
Sharpest hazards: (1) dirty-key fraction may be large at scale => payoff evaporates (Phase 1 measures it first); (2) dirty-set insert on the HOT write path (may need roaring bitmap over scan_hash) — must pass headtohead qps gate; (3) base save still hits the floor => bimodal tail; (4) delta-chain crash-safety — needs crash-injection, not just unit tests.
5. Acceptance gate
Perf:SNAPSHOT=1 SNAPSHOT_INTERVAL_SECS=2 MAXMEMORY=512mb scripts/bench/tail.sh on c7g (owner-gated; macOS can't measure). Report delta-save p99.9 AND base-save p99.9 separately (bimodal). Success = delta-save p99.9 materially below 291ms toward ms-class; base-save ~291ms and documented. Record the dirty-key-bytes fraction from Phase 1.
Correctness: extend snapshot-forward-load CI (mixed-version-compat.yml): v1 base under v2, v2 base+chain round-trip, v1 fail-close on v2. Crash-recovery ([TASK]: Seeded fault-injection and corruption scenarios #100): kill mid-delta => last committed prefix, torn delta => CRC-truncate. cargo test --workspace --all-features green (mind the load-dependent raft deadlock; use the timeout stopgap).
6. Honest ceiling
Reaches "much better than 291ms" at low write ratios (plausibly 30-80ms delta-save), NOT par with Redis ~15ms, and the tail stays bimodal because base saves re-hit the floor. Redis's 15ms is a fork+COW artifact (child on another core pays the read); IronCache is forkless + all-cores-busy by design, so there is no free core/bandwidth to offload to. Dragonfly doesn't get par for free either (PR #4910: forkless serialize dropped 250K->8K qps, recovered to 70-80K). The payoff is contingent on a SMALL dirty-key set, which is unmeasured. So: ship Phase 0 + Phase 1 unconditionally; gate the XL Phase 2/3 on the measured dirty fraction; if it's large, document the residual as a principled forkless thread-per-core tradeoff rather than spend XL on faith.
Design spike: close the during-snapshot p99.9 (291ms -> ms-class) via per-key incremental delta snapshots
Owner go/no-go. Recommendation: GO Phase 0 + Phase 1; gate Phase 2/3 on a measurement.
1. Root-cause verdict (CONFIRMED)
The 291ms during-snapshot p99.9 is a fundamental shared memory-bandwidth floor, not a scheduling bug. The
ic-persist-<shard>thread reads EVERY frozen entry once, deep-clones it (to_kvobj, store/src/kvobj.rs:2912), and encodes it (encode_kvobj, repl/src/kvcodec.rs:156) into the body Vec. On an all-cores-busy thread-per-core datapath (ADR-0002) this full-keyspace read saturates the memory controller and lengthens every concurrent op's tail. The write+fsync is once-per-shard and negligible.Proof it is bandwidth, not CPU/scheduling — the five failed levers (all c7g):
This corrects the "just yield like Dragonfly PR #4910" framing: that fixes CPU starvation; IronCache already tried the yield family and it does nothing here. The only lever that moves a bandwidth floor is reading fewer bytes.
Phase 0 must include a c7g
perf statDRAM-bandwidth/LLC-miss capture to confirm this inference directly (it's currently derived from the pin/throttle A/Bs, not measured).2. Recommended lever
Incremental (dirty-tracked delta) snapshots at PER-KEY granularity. Only this reduces the persist READ footprint.
to_kvobjthenencode_kvobj). Encode directly from the frozenEntryblob for the string-family common case => ~half the persist-thread memory traffic. Ship in Phase 0.CRITICAL correction to the research: the proposed per-SLOT dirty bit does NOT work.
DEFAULT_SLOTS_PER_DB = 256andslot_index = scan_hash(key) & (slots-1)spread keys uniformly across 256 slots; at 10% SET / zipf essentially ALL 256 slots go dirty per window => ~0% read reduction. Granularity must be per-key (per-shard dirty-key set) or per-bucket.3. Design
slot_table_mut(store/src/lib.rs:786), mirroring the reserved OnWrite attach point (touch_watch, lib.rs:800). Single-writer per shard => no atomics.ShardDumpBuilder(persist/src/lib.rs:261) skips entries whose key isn't in the dirty set captured at the epoch cut; emits TOMBSTONEs for deletes. Base = full dump + clear set (inside thesavingwindow). Delta = dirty keys + tombstones..icss+dump.manifestas sole commit point (tmp->fsync->rename->dir-fsync). Manifest gains a per-shard CHAIN{base, [deltas], base_epoch, chain_epochs}; each delta is self-describing (base_epoch, own epoch, record type {PUT,TOMBSTONE}, body CRC32). v1 fail-closes on v2 (harden: format-version-mismatched snapshots must fail loudly, not boot silently empty #530).unsafe impl Send for FrozenSlotcontract and thesaving-gated freq-bump.max_deltas_per_base(default ~8, tunable); on overflow the next save is a full base and the chain resets. Bounds warm-start replay + disk.#![forbid(unsafe_code)].4. Effort / risk / phasing — XL, decomposed
Sharpest hazards: (1) dirty-key fraction may be large at scale => payoff evaporates (Phase 1 measures it first); (2) dirty-set insert on the HOT write path (may need roaring bitmap over scan_hash) — must pass headtohead qps gate; (3) base save still hits the floor => bimodal tail; (4) delta-chain crash-safety — needs crash-injection, not just unit tests.
5. Acceptance gate
SNAPSHOT=1 SNAPSHOT_INTERVAL_SECS=2 MAXMEMORY=512mb scripts/bench/tail.shon c7g (owner-gated; macOS can't measure). Report delta-save p99.9 AND base-save p99.9 separately (bimodal). Success = delta-save p99.9 materially below 291ms toward ms-class; base-save ~291ms and documented. Record the dirty-key-bytes fraction from Phase 1.scripts/bench/headtohead.shqps flat (perf-gate budget protocol per [TASK]: Dash-style extendible-hashing table (clear uniform win on memory AND O(1) eviction) #285).cargo test --workspace --all-featuresgreen (mind the load-dependent raft deadlock; use the timeout stopgap).6. Honest ceiling
Reaches "much better than 291ms" at low write ratios (plausibly 30-80ms delta-save), NOT par with Redis ~15ms, and the tail stays bimodal because base saves re-hit the floor. Redis's 15ms is a fork+COW artifact (child on another core pays the read); IronCache is forkless + all-cores-busy by design, so there is no free core/bandwidth to offload to. Dragonfly doesn't get par for free either (PR #4910: forkless serialize dropped 250K->8K qps, recovered to 70-80K). The payoff is contingent on a SMALL dirty-key set, which is unmeasured. So: ship Phase 0 + Phase 1 unconditionally; gate the XL Phase 2/3 on the measured dirty fraction; if it's large, document the residual as a principled forkless thread-per-core tradeoff rather than spend XL on faith.
Refs: store/src/lib.rs:786 (write funnel), :3099 (begin_save), :3050 (FrozenSlot), persist/src/lib.rs:190/261, format.rs:45/51 (MAGIC/FORMAT_VERSION), kvobj.rs:2912, kvcodec.rs:156; docs/design/SNAPSHOT.md, docs/bench/OPTIMIZATION_LOG.md; issues #571 #576 #577 #578 #585 #586 #588 #589 #590 #33 #67 #100 #530.