Skip to content

Add weight-based eviction to ConcurrentLfu#814

Draft
bitfaster wants to merge 8 commits into
mainfrom
users/alexpeck/weightedlfu
Draft

Add weight-based eviction to ConcurrentLfu#814
bitfaster wants to merge 8 commits into
mainfrom
users/alexpeck/weightedlfu

Conversation

@bitfaster

Copy link
Copy Markdown
Owner

Summary

Adds opt-in weight-based eviction to ConcurrentLfu, faithfully matching Caffeine's weighted W-TinyLFU. A user supplies an IWeigher<K, V>; the cache is then bounded by total weight instead of item count.

var lfu = new ConcurrentLfuBuilder<string, byte[]>()
    .WithCapacity(1_000_000)              // maximum total weight
    .WithWeigher(new ByteArrayWeigher())  // IWeigher<K,V>.Weigh => value.Length
    .Build();

WithWeigher composes with WithEvents and WithExpireAfterWrite/WithExpireAfterAccess/WithExpireAfter.

Design

  • No new generic. Weighting is carried by the existing INodePolicy slot via two new policy structs (WeightedAccessOrderPolicy, WeightedExpireAfterPolicy). ConcurrentLfuCore/TimerWheel arities are unchanged.
  • Zero cost to the default (unweighted) path. Weighted branches are selected by static readonly bool IsWeighted = default(P).IsWeighted and JIT-elided in the count case. Weight is stored on weighted node subclasses, so unweighted node layout is untouched.
  • policyWeight-authoritative accounting maintains the invariant weightedSize == Σ policyWeight, which is robust to BitFaster's buffered/duplicated writes.
  • Ports Caffeine's weighted evictFromWindow/evictFromMain (zero-weight skips, oversize-candidate eviction, frequency admission with the >=6/1-in-128 anti-hashflood jitter), oversize-on-add, conditional weight-change handling, zero-weight survival, and the absolute-weight hill-climb (increase/decreaseWindow moving whole nodes within quota).
  • IBoundedPolicy.Capacity stays int (the weight budget); internal accounting is long. With a weigher, zero/low-weight entries mean Count can exceed Capacity (by design).

Scope

In: ConcurrentLfu (with/without events) and ConcurrentTLfu (weight composed with time expiry). Deferred: FastConcurrentLfu standalone exposure and the scoped/atomic/async builder wrappers.

Testing

  • Full suite: 1563 pass, no regressions; builds on all TFMs (netstandard2.0, netcoreapp3.1, net6.0, net10.0, net48).
  • New coverage: weighted accounting/eviction/oversize/zero-weight/weight-change/promotion invariants, the hill-climb adaptation, all builder combinations, the shared cache suite run against the weighted+events wrapper, and a 4-thread concurrency soak that reuses the integrity checker and asserts the weighted-size invariant under load.
  • A rubber-duck review caught a critical bug (probation→protected read promotion was using the unweighted path, corrupting mainProtectedWeightedSize) — fixed and regression-tested — plus three weighted+expiry hazards (deschedule-on-remove, skip AfterWrite on evict, idempotent discount).

Note

TimerWheelTests.WhenRescheduledLaterNodeIsMoved is a pre-existing, date/wall-clock-dependent test (uses real Duration.SinceEpoch()); it fails on a clean main independent of this change.

Draft

Opened as a draft for review of the approach and API shape before finalizing.

Alex Peck and others added 7 commits June 19, 2026 11:38
Design/phasing plan to add Caffeine-parity weight-based eviction to ConcurrentLfu and ConcurrentTLfu via the existing INodePolicy seam (no new generic), with weighted node subclasses.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add IWeigher, builder WithWeigher + factory wiring (weighted no-events via FastConcurrentLfu), the INodePolicy sizing seam (IsWeighted/GetWeight/GetPolicyWeight/SetPolicyWeight) implemented trivially by the unweighted policies, weighted node subclasses, weighted access/expiry node policies, and LfuNodeList.AddFirst/MoveToFront. No change to the unweighted path.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add policyWeight-authoritative weighted size accounting (weightedSize/window/protected), weighted OnWrite add/update/remove with oversize handling, weighted EvictFromWindow/EvictFromMain with frequency admission (incl. 1/128 anti-hashflood), weighted PromoteProbation/ReFitProtected, zero-weight survival, and weighted-aware OnAccess promotion. Maintains the invariant weightedSize == sum of policyWeight. Count path unchanged (JIT-elided via IsWeighted).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adapt window/main-protected maximums in weight units via Caffeine's hill-climbing (DetermineWeightedAdjustment + IncreaseWindow/DecreaseWindow moving whole nodes between queues within quota, capped at QUEUE_TRANSFER_THRESHOLD). Replaces the count-based OptimizePartitioning for weighted caches. Count path unchanged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add INodePolicy.ExpiryCalculator so wrappers expose the time policy without depending on the concrete policy type; refactor FastConcurrentLfu to use it (now supports weighted+expiry no-events). Add WeightedConcurrentLfu<K,V,N,P> generic events wrapper for weighted+events and weighted+time+events. Factory now routes all four weighted combos. Tests cover all combos plus the shared cache suite against the weighted+events wrapper.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add a weighted concurrency soak test (4 threads x get/update/remove) that reuses the generic integrity checker and asserts the weighted-size invariant holds under load. Document weighted eviction (WithWeigher) in the README. Validated across all target frameworks including net48.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The iteration parameter was unused, so the dotnet format analyzer pass (IDE0060) removed it, causing xUnit to fail binding the [Repeat]-provided argument. Use the parameter so the test binds and runs the full workload.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@bitfaster bitfaster changed the title Add weight-based eviction to ConcurrentLfu (Caffeine W-TinyLFU parity) Add weight-based eviction to ConcurrentLfu Jun 22, 2026
Matches the IExpiryCalculator naming convention. Pure rename of the public interface and its method (plus implementers and usages); no behavioral change.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@coveralls

coveralls commented Jun 22, 2026

Copy link
Copy Markdown

Coverage Status

coverage: 97.363% (-1.9%) from 99.226% — users/alexpeck/weightedlfu into main

@bitfaster bitfaster force-pushed the users/alexpeck/weightedlfu branch from 286cb79 to 5c80835 Compare June 22, 2026 20:56
@ben-manes

Copy link
Copy Markdown

fwiw, this paper argued for a different weighted admission scheme than Caffeine's (compares an accumulation).

My concerns with the approach was that under concurrency this becomes harder to reason about because the cache's state is constantly changing. There is still a need for an evaluation loop to handle races, e.g. the entry's weight might change mid evaluation or it could be explicitly removed. Since weight-based caches are less common for on-heap application caches (hard to size objects, smaller, use less memory) it seemed like an optimization that could be deferred. The approach would make more sense for persisted or dedicated remote cache servers which might hold hundreds of millions of entries, but also have vastly different needs and design goals. You might find something interesting to borrow as I liked the idea conceptually but there was a marginal hit rate increase and no real world advocate.

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.

3 participants