perf(wal): fix the high-stream-cardinality write cliff (1M streams → 1.11M ops/s @ 16 vCPU)#4676
Merged
Merged
Conversation
… + per-append meta flush) At high stream cardinality (200k-1M) nearly every append is its stream's first touch of the checkpoint interval, which turned three amortized costs into per-op costs. Local repro (6 cores, 256 conns, 400k streams): 16.0k -> 44.2k ops/s (+2.8x), p99 144ms -> 27ms. - checkpoint drain: O(1) dirty-lock critical section (take + epoch bump only); the O(touched) tail/file capture ran under the lock and stalled every appender on the shard for 25-140ms per tick. - checkpoint body: run capture + per-stream fdatasyncs + tails/ckpt persist + recycle in ONE spawn_blocking task (was: capture/tails/recycle on the async runtime); shards checkpoint concurrently (JoinSet) instead of serially. - tails map: resident in memory (seeded once from disk); stop re-reading, re-parsing and re-sorting the whole cumulative file every 3s. - meta sidecar: append path no longer debounce-schedules write_meta_sync (JSON + File::create + rename per append -> all workers spinning on the data-dir inode rwsem, ~40% of server CPU in perf at every cardinality). WAL-staged appends just mark meta_dirty; the checkpoint flushes sidecars for drained streams after recycle. Memory-mode appends keep the debounced flush. Producer/access lag bound moves from the 100ms debounce to the checkpoint cadence (already a documented non-durable, lagging flush). - handle_append: drop the redundant second registry lookup for the is_json metric label (2x cold DashMap walk per append at 1M keys). - telemetry: WAL_CKPT per-shard checkpoint phase line (touched/tails/meta counts, capture/fsync/tails/rest/meta timings) behind --wal-stats; repro script summarizes it, plus --tmpfs / --wal-stats flags. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sults Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
At high stream cardinality (200k–1M streams) write throughput fell off a cliff even after #4675's coordination fixes — at a fixed local load, going 20k→400k streams cost −65% throughput while server CPU dropped. This PR removes the four costs behind that cliff. Local repro at 400k streams: 16.0k → ~44k ops/s, p99 144 → 27 ms; on GKE at 16 vCPU, 1M streams goes 862k → 1,114,644 ops/s (ladder still unsaturated at 64 pods) and worst-case latency at the baseline rung drops 405 ms → 150 ms. The 500k→1M degradation at equal load is now −17% (was a cliff).
Root cause — why cardinality broke the amortization
The write path amortizes several costs to "once per stream per ~3 s checkpoint interval". At high cardinality, ops/stream/interval < 1, so every such cost silently becomes a per-op cost — and some of them ran under locks the hot path needs. Concretely (each confirmed with
WAL_CKPTphase telemetry added here, plusperf):dirtymutex during O(touched) work (shard.rs). The drain captured each touched stream's tail/file (shared.read()+ Arc clone, ~8–20k streams/shard/tick) inside the mutex — 25–140 ms holds. Meanwhile the Tier-1a "lock-free" fast path is dead at high cardinality: nearly every append is its stream's first touch of the epoch, so every append takes the transition path through that same mutex. Measured:dirty_wait_load0.01 → 0.30 cores as streams went 20k → 400k; client p99 (144 ms) ≈ max drain hold (140 ms).spawn_blocking; the capture, the cumulative tails-file rewrite (re-read + re-parse + re-sort + re-format of every stream ever touched, every 3 s), and recycle all stalled runtime workers.write_meta_sync(JSON +File::create(.meta.tmp)+rename). The 100 ms debounce only coalesces when a stream is re-appended within 100 ms — never, at high cardinality — so the server did one create+rename in the shared data directory per append, and all workers spun on that directory's inode rwsem.perf:osq_lock+rwsem_spin_on_ownerunderwrite_meta_sync= 38–46% of all server CPU, at every cardinality.handle_appenddid astore.get(&path)just for theis_jsonmetric label, thenhandle_append_innerdid the real one — 2× SipHash + cold DashMap walk per op at 1M keys.What unlocked the performance
register_dirty's push uses, and the recycle invariant was never anchored on capture position (it's protected by snapshottingcheckpoint_lsnbefore the drain).spawn_blocking(capture → per-stream fdatasyncs → tails persist → checkpoint_lsn persist → recycle, same hard ordering), with shards checkpointing concurrently (JoinSet) instead of serially.meta_dirty(one atomic store — also drops atokio::spawned debounce timer per append); the checkpoint writes sidecars for drained streams after recycle, off the hot path. Memory-mode appends keep the debounced flush (no checkpoint exists to pick them up).handle_append_innerreturnsis_jsonfor the metric label.WAL_CKPTtelemetry (--wal-stats): per-shard checkpoint line with touched/tails/meta counts and capture/fsync/tails/rest/meta phase timings — this is what attributed the cliff, and it's what a future run reads to decide the next fix.Key invariants (unchanged)
checkpoint_lsnis snapshotted before the drain, so any record durable at snapshot time is in the drained set (itsregister_dirtyhappened-before the commit that made it durable).Non-goals / follow-ups
packages/durable-streams-rust/CARDINALITY_1M.md.Verification
cargo test --release— 95 unit/e2e tests pass (incl. WAL recovery + checkpoint ordering tests).pnpm run test:conformance— 326 conformance tests pass.scripts/contention-repro-linux.sh --shards 6 --connections 256 --streams 400000 --tmpfs 4gbefore/after (16.0k → ~44k ops/s; p99 144 → 27 ms; 20k-stream rung also ~2× from the meta-flush fix alone).ds-benchsuiterun-durable-cpu16-1m-card(results + provenance + caveats inds-bench/results/run-durable-cpu16-1m-card/FINDINGS.md): 1M streams 899k @ 32 pods (max 150 ms) → 1.115M @ 64 pods, unsaturated; 500k @ 48 pods 1.110M.Files
src/wal/shard.rs— O(1) drain; checkpoint body in one blocking task; resident tails map; checkpoint-time meta flush;WAL_CKPTtimings.src/main.rs— concurrent per-shard checkpoint ticker.src/handlers.rs— mark-meta_dirtyinstead of per-append flush (WAL mode); single registry lookup.scripts/contention-repro-linux.sh—--tmpfs/--wal-statsknobs +WAL_CKPTsummary.CARDINALITY_1M.md— investigation + results write-up.🤖 Generated with Claude Code
https://claude.ai/code/session_01WzV7avRUMhPqmm1SgU4z1J