Use CLOCK (second-chance) eviction in the UFFD page cache#274
Conversation
Self-contained benchmark package comparing eviction policies for the pager's shared page cache under a workload that models many VMs restoring from a few shared backing snapshots (skewed popularity, a shared hot core per snapshot, and a one-shot scan polluter). Policies: prod (wraps the shipping cache), fifo (current behavior), lru, clock (second-chance), tinylfu (W-TinyLFU-lite), and ristretto. Read-path locking reflects each policy's real requirement so concurrency is measured faithfully. Includes a hit-rate comparison, a hot-shared-key read benchmark across shard counts and parallelism, and a p99-latency-under-concurrency test. Not wired into the pager; experiment only. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Models the observed production shape: large per-snapshot working set (cache holds only ~5 snapshots) with many distinct snapshots, so the cache is heavily oversubscribed. Adds a Bursty config to compare sequential per-snapshot fan-out against concurrent fan-out across snapshots — the temporal pattern that flips the recency-vs-frequency tradeoff. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Models a stable always-hot snapshot set reused across the whole timeline alongside customer-specific snapshots that fan out in waves then go idle. Reports hit rate split by hot vs tail so we can see which policy keeps the hot set resident while customers churn. Adds explicit per-snapshot fork counts and a hot/tail schedule to the generator. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Borrow now marks an atomic reference bit under the read lock instead of leaving recency untracked, and eviction sweeps from the back giving referenced entries one reprieve before reclaiming the first unreferenced one. This keeps continuously-faulted snapshot pages resident while idle snapshots fall out, without taking a write lock on the read path, so concurrent faults on hot pages don't serialize. Bumps the pager VERSION since a runtime file changed.
Drops the throwaway lib/uffdpager/cachebench benchmark package and the ristretto dependency it pulled in, now that CLOCK is implemented directly.
|
Created a monitoring plan for this PR. What this PR does: Replaces the UFFD page cache eviction policy in hypeman from LRU to CLOCK (second-chance), reducing write-lock contention on the cache hot path under concurrent page faults. Intended effect:
Risks:
Status updates will be posted automatically on this PR as monitoring progresses. |
|
Created a monitoring plan for this PR. What this PR does: Improves instance spawn throughput under concurrent workloads by switching hypeman's internal page cache from LRU to CLOCK (second-chance) eviction, reducing lock contention on the cache hit path. Intended effect:
Risks:
Status updates will be posted automatically on this PR as monitoring progresses. |
rgarcia
left a comment
There was a problem hiding this comment.
reviewed the CLOCK switch end to end — read-path change is clean and the version bump is correct. one thing i'd like confirmed (a saturation edge case) plus a couple of test-coverage gaps. nothing here blocks; leaning comment-not-changes since the main item is "is this intended" rather than a definite defect.
question — does a just-faulted page get evicted on insert under a saturated hot shard?
Add pushes new entries at the front with ref=false (lib/uffdpager/cache.go:128), but the eviction sweep also moves spared (referenced) entries to the front (lib/uffdpager/cache.go:172) — ahead of the entry just pushed. if every resident page in the shard is referenced, the sweep relocates all of them in front of the new page, the new page ends up at the back, and (bit still false) gets evicted on that same Add. trace, 2-page shard, p1/p2 both hot:
Add(p3): ring [p3(0), p1(1), p2(1)] bytes 3 > 2
back=p2(1) → clear, MoveToFront → [p2(0), p3(0), p1(1)]
back=p1(1) → clear, MoveToFront → [p1(0), p2(0), p3(0)]
back=p3(0) → EVICT p3
so the page just read from backing (lib/uffdpager/server_faults_linux.go:197) is dropped, and the next fault for it misses → re-reads from disk → re-adds → evicted again. it only triggers when the shard is fully referenced (if any resident entry is unreferenced, that one is the victim and the new page survives) — but the "continuously-faulted hot set that saturates a shard" workload this PR targets is exactly when that can happen, and a newly-hot page can't get a foothold.
conventional CLOCK fix is to insert with the bit set (PushFront then ref.Store(true), mirroring the update path) so a new page gets one grace sweep. costs one extra sweep before a genuine one-off ages out. your call — but if the current behavior is intentional it's worth a comment, because it reads as accidental.
question (design) — sampled LRU considered?
agree the list-LRU read-side write lock is genuinely bad here — shared-snapshot restores fault identical keys concurrently, so they'd all serialize on one shard lock — so CLOCK is a reasonable pick. did you weigh redis-style sampled LRU (atomic last-access counter set under the read lock, sample-K-and-evict-oldest)? closer to true-LRU semantics and also avoids the read-side write lock. not advocating a rewrite, just curious whether it was on the table.
nit
lib/uffdpager/cache.go:123vs:128— the ref-bit asymmetry (update path setstrue, insert leavesfalse) is the root of the question above and isn't documented anywhere. one line on thePushFrontwould save the next reader.
test coverage
- the
-raceclaim in the PR body rests on concurrent readers, but all three cache tests are single-goroutine, so-racenever exercises a concurrentref.Store. a small goroutine fan-out hammeringBorrowon one key would make that claim real — the atomic onrefexists specifically for that path. TestPageCacheSecondChanceEvictsUnreferencedcovers "one spared + one unreferenced victim" but not the fully-referenced sweep (all bits set → full clear pass → eviction), which is both the degenerate CLOCK path and the one in the question above. worth a test pinning whatever behavior you settle on.
otherwise lgtm — lookup collapsing to a single RLock path is a nice simplification, and the VERSION bump is required (it's go:embed'd and gates the versioned systemd unit), not cosmetic.
…weep Without the grace bit, evictLocked could relocate every spared entry ahead of the just-inserted page (via MoveToFront), leaving the new page at the back with ref=false. In a fully-referenced shard the same Add that fetched the page would then evict it, causing a re-fetch + re-evict loop on continuously-hot pages — the exact workload CLOCK is meant to serve well. Matches the classic CLOCK convention (new entries enter referenced) and makes both the insertion and update paths symmetric on the ref bit. Adds a regression test for the fully-referenced-shard case, rewrites the second-chance eviction test for the new semantics, and adds a concurrent Borrow test so -race actually exercises the atomic ref.Store. Bumps the pager VERSION since this changes eviction behavior.
The CLOCK swap commit already bumped 0.1.2 -> 0.1.3; both behavioral changes ship together in this PR, so one bump is sufficient.
|
saturation bug — real, trace was exact. fixed in 6a7174a ( sampled LRU — honest: didn't seriously weigh it at decision time. ran it just now in the harness on all three workloads + concurrency. within noise on hot+tail (both saturate at the 88.26% ceiling by the 16 GB-equiv cap). slru edges CLOCK ~0.5pt on concurrent chrome, CLOCK edges slru ~1.4pt on bursty at tight sizes. concurrency profile essentially identical (both RLock + atomic on read). sticking with CLOCK on the tie-breakers — scan resistance, 1 atomic vs 2 on Borrow, already shipped, no swap-with-tail slot management. revisit if real doc nit — resolved by the fix (both paths now set the bit, asymmetry gone). test gaps — both filled. |
rgarcia
left a comment
There was a problem hiding this comment.
re-reviewed the follow-up — all four points from the last pass are resolved. approving.
- saturation bug — fixed in
6a7174a(ref=trueon insert, mirroring the update path). i verified the regression testTestPageCacheNewEntrySurvivesFullyReferencedShardfails pre-fix and passes post, so it's pinning exactly the right behavior — the freshly-faulted page now gets its one grace sweep instead of being evicted on the sameAdd. termination still holds (the new bit gets cleared in the same sweep, so it's one grace period, not permanent immunity). - concurrency —
TestPageCacheConcurrentBorrowIsRaceFree(32×1000 concurrent borrows on one hot key) makes the-raceclaim real; passes clean under-race. that was the gap before — nothing concurrent was actually exercising the atomicref.Store. - ref-bit asymmetry nit — gone; both insert and update set the bit and the doc comment now matches both paths.
- sampled LRU — thanks for actually running it through the harness rather than hand-waving. agree CLOCK's the right call on the tie-breakers (scan resistance, one atomic vs two on
Borrow, no slot management), and revisiting if real/statstraces show a different shape is the right posture. - VERSION — net
0.1.2 -> 0.1.3vs main, so the rollout gate is intact; dropping the redundant second bump to0.1.4is correct since it all ships as one deploy.
lgtm.
Summary
Switches the pager's shared page cache (
lib/uffdpager/cache.go) from its current insertion-order (FIFO-like) eviction to CLOCK / second-chance, which approximates LRU without taking a write lock on the read path.Borrow(the fault hot path) andGetset it under the read lock — no list reordering — so concurrent faults on the same hot pages don't serialize behind a write lock.Why
Continuously-faulted snapshot pages (the always-hot snapshot set) keep getting their reference bit re-set, so they stay resident, while idle/one-off snapshots age out — the retention behavior we want as the cache oversubscribes across many backing snapshots. The current FIFO behavior ignores reuse and can evict a hot page that's actively being faulted; true LRU would fix that but only by reordering a list under an exclusive lock, which would serialize simultaneous restores. CLOCK gets the recency benefit at the read-lock cost.
Bumps
lib/uffdpager/VERSIONsince a pager runtime file changed.Test plan
go test ./lib/uffdpager/passes-raceclean on the cache tests (the reference bit is set by concurrent readers under RLock)Note
Medium Risk
Changes core pager cache retention and concurrency on the fault hot path; behavior shifts from FIFO toward recency-aware eviction, with new edge cases covered by tests.
Overview
Replaces FIFO/LRU-style list reordering in
lib/uffdpager’s sharedPageCachewith CLOCK (second-chance) eviction, so hot snapshot pages are retained without taking a write lock on the fault path.Each entry gets an
atomic.Boolreference bit set onGet/Borrow/Addunder the shard read lock (no ring moves).evictLockedsweeps from the back of the ring: referenced entries get one grace pass (bit cleared, moved to front); the first unreferenced entry is dropped.Borrownow always marks hits referenced (previously it skipped LRU touches).Tests cover second-chance victim selection, new inserts surviving when the shard is fully referenced, and concurrent
Borrowunder-race.lib/uffdpager/VERSIONbumps to0.1.3.Reviewed by Cursor Bugbot for commit 678f392. Bugbot is set up for automated code reviews on this repo. Configure here.