Skip to content

Design spike: close during-snapshot p99.9 (291ms) via per-key incremental delta snapshots #676

Description

@ELares

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.

  • Compression (ADR-0021): shrinks WRITE bytes, not READ. Floor untouched. Stack later.
  • DF-style versioned-iterate (SNAPSHOT.md, never shipped): still re-reads the whole keyspace; wrong tool; much larger change ([DECISION]: Epoch-based reclamation (crossbeam-epoch) vs custom drain-list framework #33 epoch GC interlock).
  • 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.
  • Phase 2 (Medium): enable delta warm-start; v1->v2 + v2->v2 forward-load CI; crash-injection ([TASK]: Seeded fault-injection and corruption scenarios #100).
  • Phase 3 (Small-Med): compaction + base-rewrite + chain metrics.

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.
  • No regression: scripts/bench/headtohead.sh qps flat (perf-gate budget protocol per [TASK]: Dash-style extendible-hashing table (clear uniform win on memory AND O(1) eviction) #285).
  • 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.

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions