From c76b35e15ce7e769b87ddb7b8e0910d5d7cc7a89 Mon Sep 17 00:00:00 2001 From: Alex Peck Date: Fri, 19 Jun 2026 11:38:11 -0700 Subject: [PATCH 1/8] Add weighted LFU eviction implementation plan 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> --- plan.md | 275 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 plan.md diff --git a/plan.md b/plan.md new file mode 100644 index 00000000..39f2ce84 --- /dev/null +++ b/plan.md @@ -0,0 +1,275 @@ +# Plan: Weight-based eviction for ConcurrentLfu (Caffeine parity) + +## 1. Goal +Add an **opt-in, weight-based** eviction policy to `ConcurrentLfu`, faithfully matching +Caffeine's weighted W-TinyLFU. A user supplies a weigher `weigh(key, value) -> int (>= 0)`; +the cache bounds the **total weight** (not item count). The default/unweighted path must +remain **behaviorally and memory identical** to today. + +Reference: `ben-manes/caffeine` `BoundedLocalCache.java` (commit `30f867a`), `Weigher.java`, +node/cache code generators (`AddMaximum.java`). + +## 2. How the current (unweighted) LFU works (baseline to preserve) +- `ConcurrentLfuCore` holds `windowLru`/`probationLru`/`protectedLru` + (`LfuNodeList`), a `CmSketch`, and an `LfuCapacityPartition` (integer + Window/Protected/Probation counts). +- Reads/writes are buffered (`readBuffer`/`writeBuffer`) then applied under + `maintenanceLock` in `Maintenance()` -> `OnAccess`/`OnWrite`, then `EvictEntries()`, + then `capacity.OptimizePartitioning(...)`, then `ReFitProtected()`. +- All sizing is **count-based**: `EvictFromWindow` runs `while windowLru.Count > + capacity.Window`; `EvictFromMain` runs `while window+probation+protected.Count > Capacity`. +- Hill-climb is **ratio-based**: `OptimizePartitioning` nudges a `mainRatio` double from + hit-rate change and recomputes integer (window, protected, probation). It does **not** + touch the LRU lists; the count loops + `ReFitProtected`/`PromoteProbation` lazily enforce + the new target counts. +- `OnWrite` handles add **and** update in one path keyed off `node.Position` + `node.list == + null`, and **always** promotes a probation entry on update (`PromoteProbation`). +- `AdmitCandidate` currently only does `candidateFreq > victimFreq` (has a `// TODO: random + factor` at `ConcurrentLfuCore.cs:891`). +- Exact eviction order and partition behavior are locked by tests + (`LfuCapacityPartitionTests`, the commented W/P/Probation assertions in `ConcurrentLfuTests`). + +## 3. Caffeine weighted algorithm (the target) — confirmed from source +- **Per-node**: `weight` (user value, set synchronously at write) and `policyWeight` + (policy's view, set during maintenance; **0 until the add drains**). Eviction reads + `policyWeight`. `makeDead` (our `Evict`) subtracts **`weight`**, not `policyWeight`. +- **Cache sizes** (`long`): `weightedSize`, `windowWeightedSize`, + `mainProtectedWeightedSize`. Probation weight is implicit + (`weightedSize - window - protected`). +- **Maximums** (`long`): `maximum`, `windowMaximum` (~1%), `mainProtectedMaximum` + (~79.2% of total = 80% of main). Climb adjusts **absolute weight quotas**. +- **evictFromWindow**: `while windowWeightedSize > windowMaximum`, move LRU window node to + **probation tail**; **skip `policyWeight == 0`** nodes; decrement `windowWeightedSize` by + `policyWeight`; return first moved node as the starting candidate. +- **evictFromMain(candidate)**: `while weightedSize > maximum`. `victim` = probation LRU, + advancing toward MRU; `candidate` = window-promoted nodes, then window LRU. Skip + zero-weight; if only one present evict it; if same node evict; **if + `candidate.policyWeight > maximum` evict candidate**; else `admit()` decides. When both + exhausted, fall back to protected LRU, then window LRU, then break. +- **admit(cand, victim)**: `candFreq > victimFreq` -> admit; else if `candFreq >= 6` -> + `1/128` random admit (anti hash-flood); else keep victim (ties favor the main-space victim). +- **Oversize on add** (after map insert, sketch incremented): `weight > maximum` -> evict + immediately; `weight > windowMaximum` -> insert at window **LRU front** (`offerFirst`); + else MRU tail. +- **Update changing weight** (`delta = newWeight - oldWeight`): apply delta to sizes; window + grow `> maximum` -> evict, grow `> windowMaximum` -> **move to LRU front**; probation/ + protected grow `> maximum` -> evict; shrink -> `onAccess` (may promote). A probation entry + whose `policyWeight > mainProtectedMaximum` is **not** promoted (kept in probation). +- **Zero weight**: legal; a size-eviction targeting a `weight == 0` node **resurrects** it; + zero-weight nodes are skipped by the evict loops and never size-evicted. +- **Hill climb** (weighted): `determineAdjustment()` produces a signed weight `adjustment` + from hit-rate change; `increaseWindow()`/`decreaseWindow()` transfer budget between + `windowMaximum` and `mainProtectedMaximum` **and physically move whole nodes** between the + deques within that quota (cap `QUEUE_TRANSFER_THRESHOLD = 1000` moves/step), returning + unused quota. Protected enforcement is `mainProtectedWeightedSize > mainProtectedMaximum`. +- **Sketch sizing**: weighted caches size the frequency sketch by **entry count**, not by the + weight maximum. + +## 4. Design principles +1. **Unweighted path untouched** — no behavior change, no node-layout change, no extra hot-path + work. Existing tests must pass unmodified. +2. **Compile-time selection** of weighted vs count via BitFaster's established patterns + (generic struct policies + `static readonly bool` JIT elision, e.g. + `EventPolicyDispatch.IsEnabled`, `FastConcurrentLfu.IsExpireAfterPolicy`). +3. **Additive public API only** (`IBoundedPolicy.Capacity` stays `int`). +4. **Faithful port** of the weighted accounting, eviction, admission, oversize, update, and + hill-climb behavior. + +## 5. Architecture — weight via `INodePolicy` (DECIDED: no new generic) +Per maintainer decision (D1), weighting is carried by the existing node policy `P`; +`ConcurrentLfuCore` keeps its `` arity. **No 6th generic, no `ISizePolicy`.** + +Two new `INodePolicy` structs are added alongside the existing `AccessOrderPolicy` / +`ExpireAfterPolicy`: +- `WeightedAccessOrderPolicy` — weighted, no expiry. +- `WeightedExpireAfterPolicy` — weighted + time expiry; **composes** the existing + `ExpireAfterPolicy` for the expiry hooks (TimerWheel scheduling) and layers weight on top, to + avoid duplicating expiry logic. + +**Selection & elision:** add `bool IsWeighted { get; }` to `INodePolicy` (a literal +`true`/`false`), and in the core use `static readonly bool IsWeighted = default(P).IsWeighted;` +(safe — the getter is a pure literal). The eviction/climb code branches on this static bool; +for the unweighted policies the JIT elides the weighted branches so the current path is +untouched (same idiom as `FastConcurrentLfu.IsExpireAfterPolicy`, +`EventPolicyDispatch.IsEnabled`). + +**Sizing seam added to `INodePolicy`** — implemented trivially by the two unweighted policies +(constants / no-ops, elided) and substantively by the two weighted policies: +- `int Weigh(K, V)` — unweighted returns 1. +- `bool IsWeighted { get; }`. +- per-node weight access on `N`: `int GetPolicyWeight(N)`, `void SetPolicyWeight(N, int)`, + `int GetWeight(N)`, `void SetWeight(N, int)`. Unweighted = `return 1` / no-op; weighted reads + the node's fields **directly** (for a weighted cache `N` *is* the weighted node type — matched + pair, see §6 — so no cast, no virtual dispatch). +- **Weighted cache-wide state** (the `long` `weightedSize` / `windowWeightedSize` / + `mainProtectedWeightedSize`, the weighted maximums, and the climb) lives **inside the weighted + policy struct** (mutable struct field, like `eventPolicy`). The core reads/updates it via + `policy.` members and drives the node-moving climb by passing itself back in + (e.g. `policy.Optimize(ref this, ...)`), mirroring the existing `ExpireEntries(ref this)` + reach-back pattern. For the unweighted policies these members are empty/no-ops. + +**Benefit of D1 vs the rejected 6th-generic option:** `INodePolicy.ExpireEntries`, +`TimerWheel.Advance`/`Expire`, and the wrapper core field arities **do not change** (no `S` +churn). **Cost:** the `INodePolicy` interface grows (all four policy structs implement the new +members) and there are two new weighted `P` structs (only +2 — expiry's afterWrite/afterAccess/ +custom modes are already collapsed into the single `ExpireAfterPolicy` via its +`IExpiryCalculator`, so there is no real combinatorial explosion). + +## 6. Node weight storage (DECIDED: weighted node subclasses) — see §12 D2 +**Weighted node subclasses** `WeightedAccessOrderNode` and `WeightedTimeOrderNode` +`WeightedTimeOrderNode` adding `int weight` + `int policyWeight`. The unweighted nodes and +their memory layout are **untouched** (no +8 bytes tax). The factory wires **matched (N, P) +pairs** — `(WeightedAccessOrderNode, WeightedAccessOrderPolicy)` and +`(WeightedTimeOrderNode, WeightedExpireAfterPolicy)` — so the weighted policy's weight accessors +read the node fields directly with no cast/virtual. + +**Alternative:** put `Weight`/`PolicyWeight` on base `LfuNode` (+8 bytes/node always, +simpler code). Rejected by default because BitFaster is memory-first and the unweighted path +should pay nothing (per critique). + +## 7. Public API & builder +- New `IWeigher` interface (mirrors `IExpiryCalculator`): `int Weigh(K key, V value)`. + Document the non-negative contract; throw `ArgumentOutOfRangeException` on negative (mirrors + Caffeine's `BoundedWeigher`). Provide a `Weigher` helper with a singleton (weight 1) and a + `Func` adapter for ergonomics. +- `ConcurrentLfuBuilder.WithWeigher(IWeigher weigher)` -> stores on `LfuInfo`. + (Consider `LfuInfo` becoming weigher-aware similar to its `expiry` handling.) +- `ConcurrentLfuFactory.Create` switch gains a `weigher != null` dimension and selects the + weighted `N`/`P` pair. Weight composes with events and with time expiry. +- **Wrapper wiring:** the public `ConcurrentLfu` hard-codes an unweighted core type, so + weighted+events needs its own wrapper that exposes `Events` — mirror the existing internal + `ConcurrentTLfu` pattern (a new internal weighted wrapper, and/or generalize + `ConcurrentTLfu` to the weighted+time+events combo). Weighted **without** events maps cleanly + onto the already-generic `FastConcurrentLfu` with the weighted `N`/`P` args. +- `WithCapacity` doc updated: "maximum total **weight** when a weigher is configured." +- `IBoundedPolicy.Capacity` stays `int` (weight budget, capped at `int.MaxValue`); internal + accounting uses `long` to avoid overflow. Document that with a weigher `Count` may exceed + `Capacity` (zero/low-weight entries). + +## 8. File-by-file changes +**New files** +- `Lfu/IWeigher.cs` — `IWeigher` + `Weigher` helpers (singleton, `Func` adapter). +- `Lfu/WeightedNodePolicy.cs` — `WeightedAccessOrderPolicy` and `WeightedExpireAfterPolicy` + (each holds the `IWeigher` + the weighted `long` state/maximums + accounting hooks + + admission + climb; the expiry one composes `ExpireAfterPolicy`). +- `Lfu/WeightedLfuNode.cs` — `WeightedAccessOrderNode`, `WeightedTimeOrderNode` (if §12 D2 = + subclasses). +- (Maybe) `Lfu/WeightedCapacityPartition.cs` — `long` maximum/windowMaximum/ + mainProtectedMaximum + `determineAdjustment`, owned by the weighted policy struct; the + node-moving increase/decrease runs in the core via a `policy.Optimize(ref this, …)` reach-back. +- (Maybe) internal weighted cache wrapper for the weighted+events (and weighted+time+events) + combos — see §7. + +**Modified** +- `Lfu/NodePolicy.cs` — `INodePolicy` interface grows the sizing seam (`IsWeighted`, `Weigh`, + `GetPolicyWeight`/`SetPolicyWeight`/`GetWeight`/`SetWeight`, and the size/climb reach-back + members); `AccessOrderPolicy` + `ExpireAfterPolicy` implement them trivially (constants/ + no-ops). **`ExpireEntries` keeps its current generic arity** (no `S`). +- `Lfu/ConcurrentLfuCore.cs` — `static readonly bool IsWeighted`; weighted branches in + `OnWrite` (split add/update/remove delta logic), `Evict`, `EvictEntries`, `EvictFromWindow`, + `EvictFromMain`, `AdmitCandidate`, `Trim`; weighted protected enforcement replacing + `ReFitProtected` for weighted; route size reads/writes through `policy.`; sketch sized by + count when weighted; `Capacity` clamp. **No new generic param.** +- `Lfu/TimerWheel.cs` — **unchanged** (arity stays the same; this is a benefit of D1). +- `Lfu/LfuNodeList.cs` — add `AddFirst(node)` / `MoveToFront(node)` primitives (needed for + oversize-add `offerFirst` and update `moveToFront`). +- `Lfu/LfuNode.cs` — only if §12 D2 = base fields. +- `Lfu/ConcurrentTLfu.cs` (+ any new weighted wrapper), `Lfu/FastConcurrentLfu.cs` — accept the + weighted `N`/`P` type args; `ConcurrentLfu.cs` is unchanged unless a weighted+events wrapper + reuses it. +- `Lfu/Builder/LfuInfo.cs`, `Lfu/ConcurrentLfuBuilder.cs` (+ `LfuBuilderBase` if exposed + broadly), `ConcurrentLfuBuilderExtensions.cs` — `WithWeigher` + factory wiring. +- README / API docs — document weighted eviction + capacity semantics. + +## 9. Algorithm port details (the crux) +**Accounting is `PolicyWeight`-authoritative (avoids buffer double-count):** +- New add (in `OnWrite`, `node.list == null && !WasRemoved`): `delta = Weight`; + `weightedSize += delta`; `windowWeightedSize += delta`; set `PolicyWeight = Weight`. +- Existing update: `delta = Weight - PolicyWeight`; apply delta to `weightedSize` and the + node's current-queue size; set `PolicyWeight = Weight`. **Do not** auto-`PromoteProbation`; + instead follow Caffeine's conditional grow/shrink/evict/move-to-front logic. +- Remove/evict path (`WasRemoved`, and `Evict`): subtract the **accounted** amount + (`PolicyWeight`/queue-specific) — never blindly subtract `Weight` for a node whose add never + drained (guards negative accounting). On real eviction, subtract `Weight` (finalized) per + Caffeine `makeDead`. +- Duplicate buffered writes for one node must net to a single correct delta because each apply + recomputes `Weight - PolicyWeight` and then syncs `PolicyWeight`. +- Oversize-on-add: implement the `> maximum` evict / `> windowMaximum` `AddFirst` / else + `AddLast` placement. +- Zero-weight: skip in evict loops; **resurrect** on size-eviction of a `weight == 0` node. +- `EvictFromWindow`/`EvictFromMain`/`AdmitCandidate`: weighted variants per §3 (skip-zero, + candidate-oversize evict, freq compare + `>= 6` `1/128` random). The random admission is + implemented in the **weighted** path only; the existing unweighted admission is left unchanged + (D4 deferred). +- **Weighted hill-climb**: the weighted policy's `DetermineAdjustment(metrics, sampleSize)` + returns a signed weight adjustment; the core applies `IncreaseWindow`/`DecreaseWindow` (via a + `policy.Optimize(ref this, …)` reach-back) that move whole nodes between deques within quota + (cap 1000), updating window/protected sizes & maximums, then a weighted `DemoteFromProtected`. + The count path keeps `OptimizePartitioning` + `ReFitProtected` unchanged. + +## 10. Test strategy (`BitFaster.Caching.UnitTests/Lfu/`, one test file per class) +- **Builder**: `WithWeigher` builds a weighted cache; negative weight throws; composes with + events; capacity is weight budget. +- **Accounting/eviction parity** (drive with `DoMaintenance()` + a `LogLru`-style weighted + dump): basic weighted eviction by total weight; window/probation/protected weighted splits; + admission (freq compare) chooses correctly. +- **Caffeine edge cases**: entry `weight > maximum` evicted on add; `weight > windowMaximum` + placed at window front and leaves next; update grows weight -> evict/move-to-front; update + shrinks -> promote; zero-weight entries never size-evicted and allow `Count > Capacity`; + probation entry with `policyWeight > mainProtectedMaximum` stays in probation. +- **Buffer-model edge cases** (where BitFaster differs from Caffeine's task model): add then + remove before maintenance; add then multiple weight updates before first drain; remove a node + with `policyWeight == 0`; zero-weight node at an LRU head during eviction; expired weighted + node subtracts size exactly once; duplicate write-buffer entries don't double-count. +- **Weighted climb**: window grows/shrinks in weight units; converges; respects the + window>=1 / protected>=0 bounds; transfer cap honored. +- **Soak**: extend `ConcurrentLfuSoakTests` with a weighted variant asserting weighted-size + invariants (`weightedSize == sum(node.weight)`, `window+protected+probation` consistency) + hold under concurrency. +- **Hit-rate parity (optional)**: a weighted scenario in `HitRateAnalysis` cross-checked + against Caffeine numbers. + +## 11. Phased delivery +1. **Foundations** — `IWeigher`, `WithWeigher`, factory wiring, the `INodePolicy` sizing seam + + the two new weighted policy structs as no-op-until-wired stubs (no behavior change), weighted + node type(s), `LfuNodeList.AddFirst`. Gate: full build (all TFMs) + **all existing tests + green**, weighted cache builds and behaves like count. +2. **Weighted accounting** — `long` sizes/maximums (in the weighted policy), `OnWrite` + add/update/remove delta, `Evict` subtract, oversize-on-add, zero-weight resurrect. +3. **Weighted eviction** — `EvictFromWindow`/`EvictFromMain`/`AdmitCandidate` weighted + + randomness; weighted protected enforcement. +4. **Weighted climb** — `DetermineAdjustment` + node-moving increase/decrease via the + `policy.Optimize(ref this, …)` reach-back. +5. **Variants (v1 scope)** — `ConcurrentTLfu` (+ any new weighted+events wrapper) so weight + composes with time expiry (`WeightedTimeOrderNode` + `WeightedExpireAfterPolicy` together; + factory handles weigher × {none, afterWrite, afterAccess, custom expiry}), and weighted `Trim` + semantics. **Deferred to a later milestone:** `FastConcurrentLfu` standalone exposure and the + scoped/atomic/async builder wrappers. +6. **Tests + validation + docs** — full matrix above; `dotnet format`; soak; README. + +**Verified commands**: build `dotnet build BitFaster.Caching\BitFaster.Caching.csproj -c +Release` (multi-TFM: netstandard2.0, netcoreapp3.1, net6.0, net10.0). Run `dotnet format` +after changes (repo convention). Tests via the `BitFaster.Caching.UnitTests` project. + +## 12. Open decisions for the maintainer +- **D1 Seam** *(DECIDED: weight is carried by the existing `INodePolicy` slot — two new weighted + policy structs, no new generic. `ConcurrentLfuCore` arity and `TimerWheel`/`ExpireEntries` + signatures are unchanged; the `INodePolicy` interface grows a sizing seam. See §5.)* +- **D2 Node memory** *(DECIDED: weighted node subclasses `WeightedAccessOrderNode` / + `WeightedTimeOrderNode`; unweighted nodes and their layout are untouched — zero memory tax on + the default path.)* +- **D3 Scope** *(DECIDED: Core `ConcurrentLfu` + `ConcurrentTLfu` so weight composes with time + expiry — matches Caffeine. `FastConcurrentLfu` and scoped/atomic/async wrappers deferred.)* +- **D4 Unweighted admission TODO** *(DEFERRED: leave the existing unweighted admission as-is for + now; implement the `>=6` / `1/128` random admission only in the weighted path. Finishing the + unweighted TODO — which would shift unweighted eviction order/tests — is a separate later + task.)* + +## 13. Risks +- Buffered duplicate writes/removes -> double/under-count (mitigated by `PolicyWeight`-based + deltas). +- `INodePolicy` interface growth: all four policy structs must implement the new sizing-seam + members (unweighted ones trivially); mechanical and compiler-enforced. +- Accidental behavior change to the unweighted path (mitigated by compile-time elision + + existing tests as a guard). +- `int` capacity vs `long` internal accounting overflow (clamp + `long` math). From 7d6af548557aa477aeb7cd2a3025f7b4a41b3629 Mon Sep 17 00:00:00 2001 From: Alex Peck Date: Fri, 19 Jun 2026 13:53:49 -0700 Subject: [PATCH 2/8] Weighted LFU phase 1: foundations 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> --- .../Lfu/ConcurrentLfuBuilderTests.cs | 42 ++++ BitFaster.Caching/IWeigher.cs | 20 ++ BitFaster.Caching/Lfu/Builder/LfuInfo.cs | 19 ++ BitFaster.Caching/Lfu/ConcurrentLfuBuilder.cs | 32 +++ BitFaster.Caching/Lfu/LfuNode.cs | 2 +- BitFaster.Caching/Lfu/LfuNodeList.cs | 25 +++ BitFaster.Caching/Lfu/NodePolicy.cs | 33 +++ BitFaster.Caching/Lfu/WeightedLfuNode.cs | 43 ++++ BitFaster.Caching/Lfu/WeightedNodePolicy.cs | 194 ++++++++++++++++++ 9 files changed, 409 insertions(+), 1 deletion(-) create mode 100644 BitFaster.Caching/IWeigher.cs create mode 100644 BitFaster.Caching/Lfu/WeightedLfuNode.cs create mode 100644 BitFaster.Caching/Lfu/WeightedNodePolicy.cs diff --git a/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuBuilderTests.cs b/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuBuilderTests.cs index b4ce297d..9bd66517 100644 --- a/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuBuilderTests.cs +++ b/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuBuilderTests.cs @@ -560,5 +560,47 @@ public void AsAsyncWithAtomicWithScoped() lru.Should().BeAssignableTo>(); } + + [Fact] + public void WithWeigherBuildsWeightedCache() + { + ICache weighted = new ConcurrentLfuBuilder() + .WithCapacity(100) + .WithWeigher(new IntValueWeigher()) + .Build(); + + weighted.Should().BeAssignableTo, WeightedAccessOrderPolicy>>>(); + + weighted.GetOrAdd(1, k => 5); + weighted.TryGet(1, out var value).Should().BeTrue(); + value.Should().Be(5); + } + + [Fact] + public void WithWeigherAndEventsThrows() + { + var builder = new ConcurrentLfuBuilder() + .WithWeigher(new IntValueWeigher()) + .WithEvents(); + + Action act = () => builder.Build(); + act.Should().Throw(); + } + + [Fact] + public void WithWeigherAndExpireAfterWriteThrows() + { + var builder = new ConcurrentLfuBuilder() + .WithWeigher(new IntValueWeigher()) + .WithExpireAfterWrite(TimeSpan.FromSeconds(1)); + + Action act = () => builder.Build(); + act.Should().Throw(); + } + + private sealed class IntValueWeigher : IWeigher + { + public int Weigh(int key, int value) => value; + } } } diff --git a/BitFaster.Caching/IWeigher.cs b/BitFaster.Caching/IWeigher.cs new file mode 100644 index 00000000..a1b02a5f --- /dev/null +++ b/BitFaster.Caching/IWeigher.cs @@ -0,0 +1,20 @@ +namespace BitFaster.Caching +{ + /// + /// Calculates the weight of cache entries. The total weight is used to bound the cache size, + /// and to determine when an eviction is required. Entry weights are relative to each other and + /// have no unit. + /// + /// The type of keys. + /// The type of values. + public interface IWeigher + { + /// + /// Returns the weight of a cache entry. The weight must be non-negative. + /// + /// The key to weigh. + /// The value to weigh. + /// The weight of the entry. + int Weigh(K key, V value); + } +} diff --git a/BitFaster.Caching/Lfu/Builder/LfuInfo.cs b/BitFaster.Caching/Lfu/Builder/LfuInfo.cs index 97260cc7..ed6b0911 100644 --- a/BitFaster.Caching/Lfu/Builder/LfuInfo.cs +++ b/BitFaster.Caching/Lfu/Builder/LfuInfo.cs @@ -11,6 +11,8 @@ internal sealed class LfuInfo { private object? expiry = null; + private object? weigher = null; + public int Capacity { get; set; } = 128; public int ConcurrencyLevel { get; set; } = Defaults.ConcurrencyLevel; @@ -42,6 +44,23 @@ internal sealed class LfuInfo return e; } + public void SetWeigher(IWeigher weigher) => this.weigher = weigher; + + public IWeigher? GetWeigher() + { + if (this.weigher == null) + { + return null; + } + + var w = this.weigher as IWeigher; + + if (w == null) + Throw.InvalidOp($"Incompatible IWeigher value generic type argument, expected {typeof(IWeigher)} but found {this.weigher.GetType()}"); + + return w; + } + internal void ThrowIfExpirySpecified(string extensionName) { if (this.expiry != null) diff --git a/BitFaster.Caching/Lfu/ConcurrentLfuBuilder.cs b/BitFaster.Caching/Lfu/ConcurrentLfuBuilder.cs index 6b6888ad..4fbdeed2 100644 --- a/BitFaster.Caching/Lfu/ConcurrentLfuBuilder.cs +++ b/BitFaster.Caching/Lfu/ConcurrentLfuBuilder.cs @@ -46,6 +46,19 @@ public ConcurrentLfuBuilder WithExpireAfter(IExpiryCalculator expiry return this; } + /// + /// Evict using a weighted size, where the weight of each item is calculated using the specified + /// IWeigher. The cache is bounded by the total weight of all items, and the capacity is interpreted + /// as the maximum total weight. + /// + /// The weigher that determines the weight of each item. + /// A ConcurrentLfuBuilder + public ConcurrentLfuBuilder WithWeigher(IWeigher weigher) + { + this.info.SetWeigher(weigher); + return this; + } + /// public override ICache Build() { @@ -69,6 +82,13 @@ internal static ICache Create(LfuInfo info) if (info.TimeToExpireAfterAccess.HasValue && expiry != null) Throw.InvalidOp("Specifying both ExpireAfterAccess and ExpireAfter is not supported."); + var weigher = info.GetWeigher(); + + if (weigher != null) + { + return CreateWeighted(info, weigher, expiry); + } + return (info.TimeToExpireAfterWrite.HasValue, info.TimeToExpireAfterAccess.HasValue, expiry != null, info.WithEvents) switch { // time expiry, with events @@ -88,5 +108,17 @@ internal static ICache Create(LfuInfo info) _ => new ConcurrentLfu(info.ConcurrencyLevel, info.Capacity, info.Scheduler, info.KeyComparer) }; } + + private static ICache CreateWeighted(LfuInfo info, IWeigher weigher, IExpiryCalculator? expiry) + where K : notnull + { + // Weighted eviction without expiry or events. + // Weighted eviction composed with expiry or events is wired up in a later phase. + if (info.TimeToExpireAfterWrite.HasValue || info.TimeToExpireAfterAccess.HasValue || expiry != null || info.WithEvents) + Throw.InvalidOp("Weighted eviction is not yet supported in combination with expiry or events."); + + return new FastConcurrentLfu, WeightedAccessOrderPolicy>>( + info.ConcurrencyLevel, info.Capacity, info.Scheduler, info.KeyComparer, new WeightedAccessOrderPolicy>(weigher)); + } } } diff --git a/BitFaster.Caching/Lfu/LfuNode.cs b/BitFaster.Caching/Lfu/LfuNode.cs index 71842205..c3dc892b 100644 --- a/BitFaster.Caching/Lfu/LfuNode.cs +++ b/BitFaster.Caching/Lfu/LfuNode.cs @@ -142,7 +142,7 @@ public AccessOrderNode(K k, V v) : base(k, v) } } - internal sealed class TimeOrderNode : LfuNode + internal class TimeOrderNode : LfuNode where K : notnull { TimeOrderNode prevTime; diff --git a/BitFaster.Caching/Lfu/LfuNodeList.cs b/BitFaster.Caching/Lfu/LfuNodeList.cs index 3498df5c..118413fe 100644 --- a/BitFaster.Caching/Lfu/LfuNodeList.cs +++ b/BitFaster.Caching/Lfu/LfuNodeList.cs @@ -37,6 +37,12 @@ public void MoveToEnd(LfuNode node) this.AddLast(node); } + public void MoveToFront(LfuNode node) + { + this.Remove(node); + this.AddFirst(node); + } + public void AddLast(LfuNode node) { #if DEBUG @@ -55,6 +61,25 @@ public void AddLast(LfuNode node) node.list = this; } + public void AddFirst(LfuNode node) + { +#if DEBUG + ValidateNewNode(node); +#endif + + if (head == null) + { + InternalInsertNodeToEmptyList(node); + } + else + { + InternalInsertNodeBefore(head, node); + head = node; + } + + node.list = this; + } + public void Remove(LfuNode node) { #if DEBUG diff --git a/BitFaster.Caching/Lfu/NodePolicy.cs b/BitFaster.Caching/Lfu/NodePolicy.cs index 180e9747..b5200d6c 100644 --- a/BitFaster.Caching/Lfu/NodePolicy.cs +++ b/BitFaster.Caching/Lfu/NodePolicy.cs @@ -17,6 +17,13 @@ internal interface INodePolicy void AfterWrite(N node); void OnEvict(N node); void ExpireEntries

(ref ConcurrentLfuCore cache) where P : struct, INodePolicy; + + // Sizing seam. The count policies implement these trivially (constant/no-op) so the JIT elides + // the weighted code paths in the core; the weighted policies implement them substantively. + bool IsWeighted { get; } + int GetWeight(LfuNode node); + int GetPolicyWeight(LfuNode node); + void SetPolicyWeight(LfuNode node, int weight); } internal struct AccessOrderPolicy : INodePolicy, E> @@ -64,6 +71,19 @@ public void OnEvict(AccessOrderNode node) public void ExpireEntries

(ref ConcurrentLfuCore, P, E> cache) where P : struct, INodePolicy, E> { } + + public bool IsWeighted => false; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int GetWeight(LfuNode node) => 1; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int GetPolicyWeight(LfuNode node) => 1; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void SetPolicyWeight(LfuNode node, int weight) + { + } } internal struct ExpireAfterPolicy : INodePolicy, E> @@ -144,5 +164,18 @@ public void ExpireEntries

(ref ConcurrentLfuCore, P, { wheel.Advance, P, TimeOrderNode, E>(ref cache, Duration.SinceEpoch()); } + + public bool IsWeighted => false; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int GetWeight(LfuNode node) => 1; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int GetPolicyWeight(LfuNode node) => 1; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void SetPolicyWeight(LfuNode node, int weight) + { + } } } diff --git a/BitFaster.Caching/Lfu/WeightedLfuNode.cs b/BitFaster.Caching/Lfu/WeightedLfuNode.cs new file mode 100644 index 00000000..19bdd231 --- /dev/null +++ b/BitFaster.Caching/Lfu/WeightedLfuNode.cs @@ -0,0 +1,43 @@ +#nullable disable +namespace BitFaster.Caching.Lfu +{ + ///

+ /// A node used by the access order (no expiry) weighted eviction policy. Stores the entry weight + /// from the writer's perspective () and from the policy's perspective + /// (). + /// + internal sealed class WeightedAccessOrderNode : LfuNode + where K : notnull + { + public WeightedAccessOrderNode(K k, V v) + : base(k, v) + { + } + + // The weight from the writer's perspective. Set synchronously at write time, always up to date. + public int Weight; + + // The weight from the policy's perspective. Set during maintenance, 0 until the write drains. + public int PolicyWeight; + } + + /// + /// A node used by the time order (expiry) weighted eviction policy. Combines the time order links + /// with the entry weight from the writer's perspective () and from the policy's + /// perspective (). + /// + internal sealed class WeightedTimeOrderNode : TimeOrderNode + where K : notnull + { + public WeightedTimeOrderNode(K k, V v) + : base(k, v) + { + } + + // The weight from the writer's perspective. Set synchronously at write time, always up to date. + public int Weight; + + // The weight from the policy's perspective. Set during maintenance, 0 until the write drains. + public int PolicyWeight; + } +} diff --git a/BitFaster.Caching/Lfu/WeightedNodePolicy.cs b/BitFaster.Caching/Lfu/WeightedNodePolicy.cs new file mode 100644 index 00000000..4c4a40c5 --- /dev/null +++ b/BitFaster.Caching/Lfu/WeightedNodePolicy.cs @@ -0,0 +1,194 @@ +using System.Runtime.CompilerServices; + +namespace BitFaster.Caching.Lfu +{ + /// + /// A weighted node policy for caches without expiry. Each entry is weighed using the supplied + /// , and the cache is bounded by total weight. + /// + internal struct WeightedAccessOrderPolicy : INodePolicy, E> + where K : notnull + where E : struct, IEventPolicy + { + private readonly IWeigher weigher; + + public WeightedAccessOrderPolicy(IWeigher weigher) + { + this.weigher = weigher; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public WeightedAccessOrderNode Create(K key, V value) + { + return new WeightedAccessOrderNode(key, value) { Weight = Weigh(key, value) }; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool IsExpired(WeightedAccessOrderNode node) + { + return false; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void OnRead(WeightedAccessOrderNode node) + { + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void OnWrite(WeightedAccessOrderNode node) + { + // the value may have changed, recompute the weight synchronously while the node is locked + node.Weight = Weigh(node.Key, node.Value); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AfterRead(WeightedAccessOrderNode node) + { + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AfterWrite(WeightedAccessOrderNode node) + { + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void OnEvict(WeightedAccessOrderNode node) + { + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void ExpireEntries

(ref ConcurrentLfuCore, P, E> cache) where P : struct, INodePolicy, E> + { + } + + public bool IsWeighted => true; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int GetWeight(LfuNode node) => ((WeightedAccessOrderNode)node).Weight; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int GetPolicyWeight(LfuNode node) => ((WeightedAccessOrderNode)node).PolicyWeight; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void SetPolicyWeight(LfuNode node, int weight) => ((WeightedAccessOrderNode)node).PolicyWeight = weight; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private int Weigh(K key, V value) + { + int weight = weigher.Weigh(key, value); + + if (weight < 0) + Throw.ArgOutOfRange(nameof(weight), "Weigher must return a non-negative weight."); + + return weight; + } + } + + ///

+ /// A weighted node policy for caches with expiry. Each entry is weighed using the supplied + /// and expires using the supplied . + /// + internal struct WeightedExpireAfterPolicy : INodePolicy, E> + where K : notnull + where E : struct, IEventPolicy + { + private readonly IWeigher weigher; + private readonly IExpiryCalculator expiryCalculator; + private readonly TimerWheel wheel; + private Duration current; + + public WeightedExpireAfterPolicy(IWeigher weigher, IExpiryCalculator expiryCalculator) + { + this.wheel = new TimerWheel(); + this.weigher = weigher; + this.expiryCalculator = expiryCalculator; + this.current = Duration.SinceEpoch(); + this.wheel.time = current.raw; + } + + public IExpiryCalculator ExpiryCalculator => expiryCalculator; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public WeightedTimeOrderNode Create(K key, V value) + { + var expiry = expiryCalculator.GetExpireAfterCreate(key, value); + return new WeightedTimeOrderNode(key, value) { TimeToExpire = Duration.SinceEpoch() + expiry, Weight = Weigh(key, value) }; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool IsExpired(WeightedTimeOrderNode node) + { + current = Duration.SinceEpoch(); + return node.TimeToExpire < current; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void OnRead(WeightedTimeOrderNode node) + { + node.TimeToExpire = current + expiryCalculator.GetExpireAfterRead(node.Key, node.Value, node.TimeToExpire - current); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void OnWrite(WeightedTimeOrderNode node) + { + var c = Duration.SinceEpoch(); + node.TimeToExpire = c + expiryCalculator.GetExpireAfterUpdate(node.Key, node.Value, node.TimeToExpire - c); + node.Weight = Weigh(node.Key, node.Value); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AfterRead(WeightedTimeOrderNode node) + { + wheel.Reschedule(node); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void AfterWrite(WeightedTimeOrderNode node) + { + // if the node is not yet scheduled, it is being created + // the time is set on create in case it is read before the buffer is processed + if (node.GetNextInTimeOrder() == null) + { + wheel.Schedule(node); + } + else + { + wheel.Reschedule(node); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void OnEvict(WeightedTimeOrderNode node) + { + TimerWheel.Deschedule(node); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void ExpireEntries

(ref ConcurrentLfuCore, P, E> cache) where P : struct, INodePolicy, E> + { + wheel.Advance, P, WeightedTimeOrderNode, E>(ref cache, Duration.SinceEpoch()); + } + + public bool IsWeighted => true; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int GetWeight(LfuNode node) => ((WeightedTimeOrderNode)node).Weight; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public int GetPolicyWeight(LfuNode node) => ((WeightedTimeOrderNode)node).PolicyWeight; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void SetPolicyWeight(LfuNode node, int weight) => ((WeightedTimeOrderNode)node).PolicyWeight = weight; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private int Weigh(K key, V value) + { + int weight = weigher.Weigh(key, value); + + if (weight < 0) + Throw.ArgOutOfRange(nameof(weight), "Weigher must return a non-negative weight."); + + return weight; + } + } +} From 56d8254ba96b31efe4f4118d3c3e4f9a3d6c23b4 Mon Sep 17 00:00:00 2001 From: Alex Peck Date: Mon, 22 Jun 2026 11:29:55 -0700 Subject: [PATCH 3/8] Weighted LFU phase 2-3: weighted accounting and eviction 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> --- .../Lfu/WeightedNodePolicyTests.cs | 139 ++++++ BitFaster.Caching/Lfu/ConcurrentLfuCore.cs | 446 +++++++++++++++++- 2 files changed, 575 insertions(+), 10 deletions(-) create mode 100644 BitFaster.Caching.UnitTests/Lfu/WeightedNodePolicyTests.cs diff --git a/BitFaster.Caching.UnitTests/Lfu/WeightedNodePolicyTests.cs b/BitFaster.Caching.UnitTests/Lfu/WeightedNodePolicyTests.cs new file mode 100644 index 00000000..636cd444 --- /dev/null +++ b/BitFaster.Caching.UnitTests/Lfu/WeightedNodePolicyTests.cs @@ -0,0 +1,139 @@ +using System.Collections.Generic; +using BitFaster.Caching.Lfu; +using BitFaster.Caching.Scheduler; +using FluentAssertions; +using Xunit; + +namespace BitFaster.Caching.UnitTests.Lfu +{ + public class WeightedNodePolicyTests + { + private const int Capacity = 100; + + private ConcurrentLfuCore, WeightedAccessOrderPolicy>, NoEventPolicy> core; + + public WeightedNodePolicyTests() + { + core = CreateWeighted(Capacity, new ValueWeigher()); + } + + private static ConcurrentLfuCore, WeightedAccessOrderPolicy>, NoEventPolicy> CreateWeighted(int capacity, IWeigher weigher) + { + var policy = new WeightedAccessOrderPolicy>(weigher); + return new(1, capacity, new NullScheduler(), EqualityComparer.Default, () => { }, policy, default); + } + + [Fact] + public void WhenItemsAddedWithinCapacityWeightedSizeEqualsTotalWeight() + { + core.AddOrUpdate(1, 10); + core.AddOrUpdate(2, 20); + core.AddOrUpdate(3, 30); + core.DoMaintenance(); + + core.Count.Should().Be(3); + core.WeightedSize.Should().Be(60); + } + + [Fact] + public void WhenTotalWeightExceedsCapacityWeightedSizeStaysWithinMaximum() + { + for (int i = 0; i < 20; i++) + { + core.AddOrUpdate(i, 30); + } + core.DoMaintenance(); + + core.WeightedSize.Should().BeLessThanOrEqualTo(Capacity); + } + + [Fact] + public void WhenItemWeightExceedsMaximumItemIsEvicted() + { + core.AddOrUpdate(1, 200); + core.DoMaintenance(); + + core.TryGet(1, out _).Should().BeFalse(); + core.Count.Should().Be(0); + core.WeightedSize.Should().Be(0); + } + + [Fact] + public void WhenItemHasZeroWeightItIsNotEvicted() + { + core.AddOrUpdate(1, 0); + core.AddOrUpdate(2, 60); + core.AddOrUpdate(3, 60); + core.DoMaintenance(); + + core.TryGet(1, out _).Should().BeTrue(); + core.WeightedSize.Should().BeLessThanOrEqualTo(Capacity); + } + + [Fact] + public void WhenItemWeightIncreasedWeightedSizeIncreases() + { + core.AddOrUpdate(1, 30); + core.DoMaintenance(); + core.WeightedSize.Should().Be(30); + + core.AddOrUpdate(1, 50); + core.DoMaintenance(); + + core.TryGet(1, out _).Should().BeTrue(); + core.WeightedSize.Should().Be(50); + } + + [Fact] + public void WhenItemWeightDecreasedWeightedSizeDecreases() + { + core.AddOrUpdate(1, 80); + core.DoMaintenance(); + core.WeightedSize.Should().Be(80); + + core.AddOrUpdate(1, 10); + core.DoMaintenance(); + + core.TryGet(1, out _).Should().BeTrue(); + core.WeightedSize.Should().Be(10); + } + + [Fact] + public void WhenItemRemovedWeightedSizeIsDiscounted() + { + core.AddOrUpdate(1, 30); + core.AddOrUpdate(2, 40); + core.DoMaintenance(); + core.WeightedSize.Should().Be(70); + + core.TryRemove(1); + core.DoMaintenance(); + + core.WeightedSize.Should().Be(40); + core.Count.Should().Be(1); + } + + [Fact] + public void WhenProbationItemReadItIsPromotedAndProtectedWeightTracked() + { + core.AddOrUpdate(1, 10); + core.AddOrUpdate(2, 20); + core.AddOrUpdate(3, 30); + core.DoMaintenance(); + + // read item 1 so it is promoted from probation to protected during maintenance + core.TryGet(1, out _); + core.TryGet(1, out _); + core.DoMaintenance(); + + core.TryGet(1, out _).Should().BeTrue(); + core.WeightedSize.Should().Be(60); + core.MainProtectedWeightedSize.Should().Be(10); + } + + private sealed class ValueWeigher : IWeigher + { + public int Weigh(int key, int value) => value; + } + } +} diff --git a/BitFaster.Caching/Lfu/ConcurrentLfuCore.cs b/BitFaster.Caching/Lfu/ConcurrentLfuCore.cs index 923cb8fa..15824610 100644 --- a/BitFaster.Caching/Lfu/ConcurrentLfuCore.cs +++ b/BitFaster.Caching/Lfu/ConcurrentLfuCore.cs @@ -51,6 +51,11 @@ internal struct ConcurrentLfuCore : IBoundedPolicy private const int DefaultBufferSize = 128; + // Weighted eviction tuning, matching Caffeine. + private const double MainPercentage = 0.99d; + private const double MainProtectedPercentage = 0.8d; + private const int AdmitHashDosThreshold = 6; + private readonly ConcurrentDictionary dictionary; internal readonly StripedMpscBuffer readBuffer; @@ -66,6 +71,17 @@ internal struct ConcurrentLfuCore : IBoundedPolicy private readonly LfuCapacityPartition capacity; + // Weighted eviction state. Used only when the node policy is weighted (IsWeighted == true); + // the JIT elides the weighted branches in the count case since IsWeighted folds to a constant. + private static readonly bool IsWeighted = default(P).IsWeighted; + private long weightedSize; + private long windowWeightedSize; + private long mainProtectedWeightedSize; + private long maximum; + private long windowMaximum; + private long mainProtectedMaximum; + private readonly Random? random; + internal readonly DrainStatus drainStatus = new(); #if NET9_0_OR_GREATER @@ -121,6 +137,15 @@ public ConcurrentLfuCore(int concurrencyLevel, int capacity, IScheduler schedule this.capacity = new LfuCapacityPartition(capacity); + if (IsWeighted) + { + // Mirror Caffeine's initial split: window ~1% of total weight, protected ~80% of main. + this.maximum = capacity; + this.windowMaximum = this.maximum - (long)(MainPercentage * this.maximum); + this.mainProtectedMaximum = (long)(MainProtectedPercentage * (this.maximum - this.windowMaximum)); + this.random = new Random(); + } + this.scheduler = scheduler; this.drainBuffer = new N[this.readBuffer.Capacity]; @@ -137,6 +162,11 @@ public ConcurrentLfuCore(int concurrencyLevel, int capacity, IScheduler schedule public int Capacity => this.capacity.Capacity; + // Weighted size accounting, exposed for tests. Valid only when the policy is weighted. + internal long WeightedSize => this.weightedSize; + internal long WindowWeightedSize => this.windowWeightedSize; + internal long MainProtectedWeightedSize => this.mainProtectedWeightedSize; + public Optional Metrics => new(this.metrics); public CachePolicy Policy => new(new Optional(this), Optional.None()); @@ -642,8 +672,16 @@ private bool Maintenance(N? droppedWrite = null, ItemRemovedReason reason = Item policy.ExpireEntries(ref this); EvictEntries(reason); - this.capacity.OptimizePartitioning(this.metrics, this.cmSketch.ResetSampleSize); - ReFitProtected(); + + if (IsWeighted) + { + ReFitProtectedWeighted(); + } + else + { + this.capacity.OptimizePartitioning(this.metrics, this.cmSketch.ResetSampleSize); + ReFitProtected(); + } // Reset to idle if either // 1. We drained both input buffers (all work done) @@ -677,7 +715,14 @@ private void OnAccess(N node) break; case Position.Probation: Debug.Assert(node.list == this.probationLru); - PromoteProbation(node); + if (IsWeighted) + { + PromoteProbationWeighted(node); + } + else + { + PromoteProbation(node); + } break; case Position.Protected: Debug.Assert(node.list == this.protectedLru); @@ -694,12 +739,26 @@ private void OnWrite(N node) // not be added back into the LRU. if (node.WasRemoved) { + bool firstTime = !node.WasDeleted; + + // discount the node's accounted weight exactly once, while it is still linked + if (IsWeighted && firstTime && node.list != null) + { + DiscountWeighted(node); + } + node.list?.Remove(node); - if (!node.WasDeleted) + if (firstTime) { // if a write is in the buffer and is then removed in the buffer, it will enter OnWrite twice. // we mark as deleted to avoid double counting/disposing it + if (IsWeighted) + { + // deschedule from any timer wheel so a stale timer entry cannot re-evict the node + policy.OnEvict(node); + } + EventPolicyDispatch.OnRemovedEvent(this, node, ItemRemovedReason.Removed); this.metrics.evictedCount++; Disposer.Dispose(node.Value); @@ -711,36 +770,166 @@ private void OnWrite(N node) this.cmSketch.Increment(node.Key); - // node can already be in one of the queues due to update + if (IsWeighted) + { + OnWriteWeighted(node); + + // OnWriteWeighted may have evicted an overweight entry; do not reschedule a dead node + if (node.WasRemoved) + { + return; + } + } + else + { + // node can already be in one of the queues due to update + switch (node.Position) + { + case Position.Window: + if (node.list == null) + { + this.windowLru.AddLast(node); + } + else + { + Debug.Assert(node.list == this.windowLru); + this.windowLru.MoveToEnd(node); + this.metrics.updatedCount++; + } + break; + case Position.Probation: + Debug.Assert(node.list == this.probationLru); + PromoteProbation(node); + this.metrics.updatedCount++; + break; + case Position.Protected: + Debug.Assert(node.list == this.protectedLru); + this.protectedLru.MoveToEnd(node); + this.metrics.updatedCount++; + break; + } + } + + policy.AfterWrite(node); + } + + private void OnWriteWeighted(N node) + { + int weight = this.policy.GetWeight(node); + switch (node.Position) { case Position.Window: if (node.list == null) { - this.windowLru.AddLast(node); + // new entry: account its weight in the window, then place it + this.policy.SetPolicyWeight(node, weight); + this.weightedSize += weight; + this.windowWeightedSize += weight; + + if (weight > this.maximum) + { + this.windowLru.AddLast(node); + Evict(node, ItemRemovedReason.Evicted); + } + else if (weight > this.windowMaximum) + { + // too big for the window, place at the LRU position so it leaves next + this.windowLru.AddFirst(node); + } + else + { + this.windowLru.AddLast(node); + } } else { Debug.Assert(node.list == this.windowLru); - this.windowLru.MoveToEnd(node); + ApplyWeightDelta(node, weight, Position.Window); this.metrics.updatedCount++; + + if (weight > this.maximum) + { + Evict(node, ItemRemovedReason.Evicted); + } + else if (weight <= this.windowMaximum) + { + this.windowLru.MoveToEnd(node); + } + else + { + this.windowLru.MoveToFront(node); + } } break; case Position.Probation: Debug.Assert(node.list == this.probationLru); - PromoteProbation(node); + ApplyWeightDelta(node, weight, Position.Probation); this.metrics.updatedCount++; + + if (weight <= this.maximum) + { + PromoteProbationWeighted(node); + } + else + { + Evict(node, ItemRemovedReason.Evicted); + } break; case Position.Protected: Debug.Assert(node.list == this.protectedLru); - this.protectedLru.MoveToEnd(node); + ApplyWeightDelta(node, weight, Position.Protected); this.metrics.updatedCount++; + + if (weight <= this.maximum) + { + this.protectedLru.MoveToEnd(node); + } + else + { + Evict(node, ItemRemovedReason.Evicted); + } break; } + } - policy.AfterWrite(node); + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void ApplyWeightDelta(LfuNode node, int newWeight, Position position) + { + int delta = newWeight - this.policy.GetPolicyWeight(node); + this.policy.SetPolicyWeight(node, newWeight); + this.weightedSize += delta; + + if (position == Position.Window) + { + this.windowWeightedSize += delta; + } + else if (position == Position.Protected) + { + this.mainProtectedWeightedSize += delta; + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void DiscountWeighted(LfuNode node) + { + int pw = this.policy.GetPolicyWeight(node); + this.weightedSize -= pw; + + if (node.Position == Position.Window) + { + this.windowWeightedSize -= pw; + } + else if (node.Position == Position.Protected) + { + this.mainProtectedWeightedSize -= pw; + } + + // clear so a stale double eviction cannot discount the same weight twice + this.policy.SetPolicyWeight(node, 0); } + private void PromoteProbation(LfuNode node) { this.probationLru.Remove(node); @@ -758,12 +947,244 @@ private void PromoteProbation(LfuNode node) } } + private void PromoteProbationWeighted(LfuNode node) + { + int pw = this.policy.GetPolicyWeight(node); + + // An entry that cannot fit in the protected space is kept in probation at the MRU position. + if (pw > this.mainProtectedMaximum) + { + this.probationLru.MoveToEnd(node); + return; + } + + this.probationLru.Remove(node); + this.protectedLru.AddLast(node); + node.Position = Position.Protected; + this.mainProtectedWeightedSize += pw; + + // If the protected space exceeds its maximum weight, demote LRU items to probation. + while (this.mainProtectedWeightedSize > this.mainProtectedMaximum) + { + var demoted = this.protectedLru.First; + if (demoted == null) + { + break; + } + + this.protectedLru.RemoveFirst(); + this.mainProtectedWeightedSize -= this.policy.GetPolicyWeight(demoted); + demoted.Position = Position.Probation; + this.probationLru.AddLast(demoted); + } + } + private void EvictEntries(ItemRemovedReason reason) { + if (IsWeighted) + { + var weightedCandidate = EvictFromWindowWeighted(); + EvictFromMainWeighted(weightedCandidate, reason); + return; + } + var candidate = EvictFromWindow(); EvictFromMain(candidate, reason); } + private LfuNode? EvictFromWindowWeighted() + { + LfuNode? first = null; + var node = this.windowLru.First; + + while (this.windowWeightedSize > this.windowMaximum) + { + if (node == null) + { + break; + } + + var next = node.Next; + int pw = this.policy.GetPolicyWeight(node); + + // zero weight entries are skipped, they do not count against the window size + if (pw != 0) + { + first ??= node; + + this.windowLru.Remove(node); + this.probationLru.AddLast(node); + node.Position = Position.Probation; + + this.windowWeightedSize -= pw; + } + + node = next; + } + + return first; + } + + // Queue identifiers used to walk victims/candidates across the three LRU lists. + private const int ProbationQueue = 0; + private const int ProtectedQueue = 1; + private const int WindowQueue = 2; + + private void EvictFromMainWeighted(LfuNode? candidateNode, ItemRemovedReason reason) + { + int victimQueue = ProbationQueue; + int candidateQueue = ProbationQueue; + + var victim = this.probationLru.First; // victims are LRU position in probation + var candidate = candidateNode; + + while (this.weightedSize > this.maximum) + { + // [A] search the admission window for additional candidates + if (candidate == null && candidateQueue == ProbationQueue) + { + candidate = this.windowLru.First; + candidateQueue = WindowQueue; + } + + // [B] try evicting from the protected and window queues + if (candidate == null && victim == null) + { + if (victimQueue == ProbationQueue) + { + victim = this.protectedLru.First; + victimQueue = ProtectedQueue; + continue; + } + else if (victimQueue == ProtectedQueue) + { + victim = this.windowLru.First; + victimQueue = WindowQueue; + continue; + } + + // the pending operations will adjust the size to reflect the correct weight + break; + } + + // [C] skip over entries with zero weight + if (victim != null && this.policy.GetPolicyWeight(victim) == 0) + { + victim = victim.Next; + continue; + } + else if (candidate != null && this.policy.GetPolicyWeight(candidate) == 0) + { + candidate = candidate.Next; + continue; + } + + // [D] evict immediately if only one of the entries is present + if (victim == null) + { + var evict = candidate!; + candidate = candidate!.Next; + Evict(evict, reason); + continue; + } + else if (candidate == null) + { + var evict = victim; + victim = victim.Next; + Evict(evict, reason); + continue; + } + + // [E] evict immediately if both selected the same entry + if (candidate == victim) + { + victim = victim.Next; + Evict(candidate, reason); + candidate = null; + continue; + } + + // [G] evict immediately if an entry was removed + if (victim.WasRemoved) + { + var evict = victim; + victim = victim.Next; + Evict(evict, reason); + continue; + } + else if (candidate.WasRemoved) + { + var evict = candidate; + candidate = candidate.Next; + Evict(evict, reason); + continue; + } + + // [H] evict immediately if the candidate's weight exceeds the maximum + if (this.policy.GetPolicyWeight(candidate) > this.maximum) + { + var evict = candidate; + candidate = candidate.Next; + Evict(evict, reason); + continue; + } + + // [I] evict the entry with the lowest frequency + if (AdmitCandidateWeighted(candidate.Key, victim.Key)) + { + var evict = victim; + victim = victim.Next; + Evict(evict, reason); + candidate = candidate.Next; + } + else + { + var evict = candidate; + candidate = candidate.Next; + Evict(evict, reason); + } + } + } + + private bool AdmitCandidateWeighted(K candidateKey, K victimKey) + { + int victimFreq = this.cmSketch.EstimateFrequency(victimKey); + int candidateFreq = this.cmSketch.EstimateFrequency(candidateKey); + + if (candidateFreq > victimFreq) + { + return true; + } + + // The maximum frequency is 15 and halved to 7 on reset to age. A candidate with a moderate + // frequency is given a small chance to be admitted to defend against hash flooding. + if (candidateFreq >= AdmitHashDosThreshold) + { + return (this.random!.Next() & 127) == 0; + } + + return false; + } + + private void ReFitProtectedWeighted() + { + // If hill climbing decreased the protected maximum, demote overflow to probation. + while (this.mainProtectedWeightedSize > this.mainProtectedMaximum) + { + var demoted = this.protectedLru.First; + if (demoted == null) + { + break; + } + + this.protectedLru.RemoveFirst(); + this.mainProtectedWeightedSize -= this.policy.GetPolicyWeight(demoted); + + demoted.Position = Position.Probation; + this.probationLru.AddLast(demoted); + } + } + private LfuNode? EvictFromWindow() { LfuNode? first = null; @@ -897,6 +1318,11 @@ internal void Evict(LfuNode evictee, ItemRemovedReason reason) evictee.WasRemoved = true; evictee.WasDeleted = true; + if (IsWeighted) + { + DiscountWeighted(evictee); + } + // This handles the case where the same key exists in the write buffer both // as added and removed. Remove via KVP ensures we don't remove added nodes. var kvp = new KeyValuePair(evictee.Key, (N)evictee); From d7e2d79914df5b0ebb7b2d514613401da383579a Mon Sep 17 00:00:00 2001 From: Alex Peck Date: Mon, 22 Jun 2026 11:37:37 -0700 Subject: [PATCH 4/8] Weighted LFU phase 4: weighted hill-climb 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> --- .../Lfu/WeightedNodePolicyTests.cs | 35 ++++ BitFaster.Caching/Lfu/ConcurrentLfuCore.cs | 162 ++++++++++++++++++ 2 files changed, 197 insertions(+) diff --git a/BitFaster.Caching.UnitTests/Lfu/WeightedNodePolicyTests.cs b/BitFaster.Caching.UnitTests/Lfu/WeightedNodePolicyTests.cs index 636cd444..c5412a4a 100644 --- a/BitFaster.Caching.UnitTests/Lfu/WeightedNodePolicyTests.cs +++ b/BitFaster.Caching.UnitTests/Lfu/WeightedNodePolicyTests.cs @@ -131,6 +131,41 @@ public void WhenProbationItemReadItIsPromotedAndProtectedWeightTracked() core.MainProtectedWeightedSize.Should().Be(10); } + [Fact] + public void WhenRecencyWorkloadWindowMaximumIncreasesAndInvariantHolds() + { + var weighted = CreateWeighted(200, new ValueWeigher()); + + for (int i = 0; i < 10; i++) + { + weighted.AddOrUpdate(i, 10); + } + weighted.DoMaintenance(); + long initialWindowMaximum = weighted.WindowMaximum; + + // a steady stream of hits on resident items drives the hill climber to grow the window + for (int round = 0; round < 100; round++) + { + for (int r = 0; r < 15; r++) + { + for (int i = 0; i < 10; i++) + { + weighted.TryGet(i, out _); + } + } + weighted.DoMaintenance(); + } + + weighted.WindowMaximum.Should().BeGreaterThan(initialWindowMaximum); + + // invariants must hold throughout adaptation + weighted.WeightedSize.Should().BeLessThanOrEqualTo(200); + weighted.WindowWeightedSize.Should().BeGreaterThanOrEqualTo(0); + weighted.MainProtectedWeightedSize.Should().BeGreaterThanOrEqualTo(0); + weighted.WindowMaximum.Should().BeGreaterThanOrEqualTo(1); + weighted.MainProtectedMaximum.Should().BeGreaterThanOrEqualTo(0); + } + private sealed class ValueWeigher : IWeigher { public int Weigh(int key, int value) => value; diff --git a/BitFaster.Caching/Lfu/ConcurrentLfuCore.cs b/BitFaster.Caching/Lfu/ConcurrentLfuCore.cs index 15824610..398d1fb1 100644 --- a/BitFaster.Caching/Lfu/ConcurrentLfuCore.cs +++ b/BitFaster.Caching/Lfu/ConcurrentLfuCore.cs @@ -55,6 +55,12 @@ internal struct ConcurrentLfuCore : IBoundedPolicy private const double MainPercentage = 0.99d; private const double MainProtectedPercentage = 0.8d; private const int AdmitHashDosThreshold = 6; + private const double HillClimberStepPercent = 0.0625d; + private const double HillClimberStepDecay = 0.98d; + private const double HillClimberRestartThreshold = 0.05d; + private const double HillClimberMinStep = 2.0d; + private const long SmallCacheThreshold = 512; + private const int QueueTransferThreshold = 1000; private readonly ConcurrentDictionary dictionary; @@ -80,6 +86,10 @@ internal struct ConcurrentLfuCore : IBoundedPolicy private long maximum; private long windowMaximum; private long mainProtectedMaximum; + private double stepSize; + private double previousHitRate; + private long previousHitCount; + private long previousMissCount; private readonly Random? random; internal readonly DrainStatus drainStatus = new(); @@ -143,6 +153,9 @@ public ConcurrentLfuCore(int concurrencyLevel, int capacity, IScheduler schedule this.maximum = capacity; this.windowMaximum = this.maximum - (long)(MainPercentage * this.maximum); this.mainProtectedMaximum = (long)(MainProtectedPercentage * (this.maximum - this.windowMaximum)); + this.previousHitRate = 1.0d; + double initialStep = Math.Max(HillClimberStepPercent * this.maximum, HillClimberMinStep); + this.stepSize = (this.maximum <= SmallCacheThreshold) ? initialStep : -initialStep; this.random = new Random(); } @@ -166,6 +179,8 @@ public ConcurrentLfuCore(int concurrencyLevel, int capacity, IScheduler schedule internal long WeightedSize => this.weightedSize; internal long WindowWeightedSize => this.windowWeightedSize; internal long MainProtectedWeightedSize => this.mainProtectedWeightedSize; + internal long WindowMaximum => this.windowMaximum; + internal long MainProtectedMaximum => this.mainProtectedMaximum; public Optional Metrics => new(this.metrics); @@ -675,6 +690,7 @@ private bool Maintenance(N? droppedWrite = null, ItemRemovedReason reason = Item if (IsWeighted) { + OptimizeWeightedPartitioning(); ReFitProtectedWeighted(); } else @@ -1185,6 +1201,152 @@ private void ReFitProtectedWeighted() } } + // Adapt the window and main space sizes (in weight units) using a hill climbing algorithm to + // iteratively improve hit rate. A larger window favors recency, a larger main favors frequency. + private void OptimizeWeightedPartitioning() + { + long adjustment = DetermineWeightedAdjustment(); + + if (adjustment > 0) + { + IncreaseWindow(adjustment); + } + else if (adjustment < 0) + { + DecreaseWindow(-adjustment); + } + } + + private long DetermineWeightedAdjustment() + { + long newHits = this.metrics.Hits; + long newMisses = this.metrics.Misses; + + long sampleHits = newHits - this.previousHitCount; + long sampleMisses = newMisses - this.previousMissCount; + long requestCount = sampleHits + sampleMisses; + + if (requestCount < this.cmSketch.ResetSampleSize) + { + return 0; + } + + double hitRate = (double)sampleHits / requestCount; + double hitRateChange = hitRate - this.previousHitRate; + double amount = (hitRateChange >= 0) ? this.stepSize : -this.stepSize; + double nextStepSize = (Math.Abs(hitRateChange) >= HillClimberRestartThreshold) + ? CopySign(Math.Max(HillClimberStepPercent * this.maximum, HillClimberMinStep), amount) + : HillClimberStepDecay * amount; + + this.previousHitRate = hitRate; + this.previousHitCount = newHits; + this.previousMissCount = newMisses; + this.stepSize = nextStepSize; + + return (long)amount; + } + + private void IncreaseWindow(long adjustment) + { + if (this.mainProtectedMaximum == 0) + { + return; + } + + long quota = Math.Min(adjustment, this.mainProtectedMaximum); + this.mainProtectedMaximum -= quota; + this.windowMaximum += quota; + + ReFitProtectedWeighted(); + + for (int i = 0; i < QueueTransferThreshold; i++) + { + var candidate = this.probationLru.First; + bool probation = true; + + if (candidate == null || quota < this.policy.GetPolicyWeight(candidate)) + { + candidate = this.protectedLru.First; + probation = false; + } + + if (candidate == null) + { + break; + } + + int weight = this.policy.GetPolicyWeight(candidate); + if (quota < weight) + { + break; + } + + quota -= weight; + + if (probation) + { + this.probationLru.Remove(candidate); + } + else + { + this.mainProtectedWeightedSize -= weight; + this.protectedLru.Remove(candidate); + } + + this.windowWeightedSize += weight; + this.windowLru.AddLast(candidate); + candidate.Position = Position.Window; + } + + // return unused quota + this.mainProtectedMaximum += quota; + this.windowMaximum -= quota; + } + + private void DecreaseWindow(long adjustment) + { + if (this.windowMaximum <= 1) + { + return; + } + + long quota = Math.Min(adjustment, Math.Max(0, this.windowMaximum - 1)); + this.mainProtectedMaximum += quota; + this.windowMaximum -= quota; + + for (int i = 0; i < QueueTransferThreshold; i++) + { + var candidate = this.windowLru.First; + if (candidate == null) + { + break; + } + + int weight = this.policy.GetPolicyWeight(candidate); + if (quota < weight) + { + break; + } + + quota -= weight; + + this.windowWeightedSize -= weight; + this.windowLru.Remove(candidate); + this.probationLru.AddLast(candidate); + candidate.Position = Position.Probation; + } + + // return unused quota + this.mainProtectedMaximum -= quota; + this.windowMaximum += quota; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static double CopySign(double magnitude, double sign) + { + return (sign < 0) ? -Math.Abs(magnitude) : Math.Abs(magnitude); + } + private LfuNode? EvictFromWindow() { LfuNode? first = null; From ffe680a07100edb1b4d67c048317cf88db421e8b Mon Sep 17 00:00:00 2001 From: Alex Peck Date: Mon, 22 Jun 2026 13:06:46 -0700 Subject: [PATCH 5/8] Weighted LFU phase 5: compose weight with events and time expiry 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 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> --- .../Lfu/ConcurrentLfuBuilderTests.cs | 40 ++- .../Lfu/ConcurrentLfuCoreTests.cs | 24 ++ .../Lfu/WeightedNodePolicyTests.cs | 43 ++- BitFaster.Caching/Lfu/ConcurrentLfuBuilder.cs | 28 +- BitFaster.Caching/Lfu/FastConcurrentLfu.cs | 25 +- BitFaster.Caching/Lfu/NodePolicy.cs | 6 + .../Lfu/WeightedConcurrentLfu.cs | 305 ++++++++++++++++++ BitFaster.Caching/Lfu/WeightedNodePolicy.cs | 2 + 8 files changed, 440 insertions(+), 33 deletions(-) create mode 100644 BitFaster.Caching/Lfu/WeightedConcurrentLfu.cs diff --git a/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuBuilderTests.cs b/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuBuilderTests.cs index 9bd66517..1d9e881a 100644 --- a/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuBuilderTests.cs +++ b/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuBuilderTests.cs @@ -577,25 +577,45 @@ public void WithWeigherBuildsWeightedCache() } [Fact] - public void WithWeigherAndEventsThrows() + public void WithWeigherAndEventsBuildsWeightedCacheWithEvents() { - var builder = new ConcurrentLfuBuilder() + ICache weighted = new ConcurrentLfuBuilder() + .WithCapacity(100) .WithWeigher(new IntValueWeigher()) - .WithEvents(); + .WithEvents() + .Build(); - Action act = () => builder.Build(); - act.Should().Throw(); + weighted.Should().BeAssignableTo, WeightedAccessOrderPolicy>>>(); + weighted.Events.HasValue.Should().BeTrue(); } [Fact] - public void WithWeigherAndExpireAfterWriteThrows() + public void WithWeigherAndExpireAfterWriteBuildsWeightedCacheWithExpiry() { - var builder = new ConcurrentLfuBuilder() + ICache weighted = new ConcurrentLfuBuilder() + .WithCapacity(100) .WithWeigher(new IntValueWeigher()) - .WithExpireAfterWrite(TimeSpan.FromSeconds(1)); + .WithExpireAfterWrite(TimeSpan.FromSeconds(1)) + .Build(); - Action act = () => builder.Build(); - act.Should().Throw(); + weighted.Should().BeAssignableTo, WeightedExpireAfterPolicy>>>(); + weighted.Policy.ExpireAfterWrite.HasValue.Should().BeTrue(); + weighted.Policy.ExpireAfterWrite.Value.TimeToLive.Should().Be(TimeSpan.FromSeconds(1)); + } + + [Fact] + public void WithWeigherAndExpireAfterWriteAndEventsBuildsWeightedCache() + { + ICache weighted = new ConcurrentLfuBuilder() + .WithCapacity(100) + .WithWeigher(new IntValueWeigher()) + .WithExpireAfterWrite(TimeSpan.FromSeconds(1)) + .WithEvents() + .Build(); + + weighted.Should().BeAssignableTo, WeightedExpireAfterPolicy>>>(); + weighted.Policy.ExpireAfterWrite.HasValue.Should().BeTrue(); + weighted.Events.HasValue.Should().BeTrue(); } private sealed class IntValueWeigher : IWeigher diff --git a/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuCoreTests.cs b/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuCoreTests.cs index 87a98db9..ae4a18c9 100644 --- a/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuCoreTests.cs +++ b/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuCoreTests.cs @@ -200,4 +200,28 @@ public override void DoMaintenance(ICache cache) tlfu?.DoMaintenance(); } } + + public class WeightedConcurrentLfuWrapperTests : ConcurrentLfuCoreTests + { + public override ICache Create() + { + return new ConcurrentLfuBuilder() + .WithCapacity(capacity) + .WithConcurrencyLevel(1) + .WithWeigher(new UnitWeigher()) + .WithEvents() + .Build(); + } + + public override void DoMaintenance(ICache cache) + { + var weighted = cache as WeightedConcurrentLfu, WeightedAccessOrderPolicy>>; + weighted?.DoMaintenance(); + } + + private sealed class UnitWeigher : IWeigher + { + public int Weigh(K key, V value) => 1; + } + } } diff --git a/BitFaster.Caching.UnitTests/Lfu/WeightedNodePolicyTests.cs b/BitFaster.Caching.UnitTests/Lfu/WeightedNodePolicyTests.cs index c5412a4a..770376d6 100644 --- a/BitFaster.Caching.UnitTests/Lfu/WeightedNodePolicyTests.cs +++ b/BitFaster.Caching.UnitTests/Lfu/WeightedNodePolicyTests.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using BitFaster.Caching.Lfu; using BitFaster.Caching.Scheduler; using FluentAssertions; @@ -166,6 +167,46 @@ public void WhenRecencyWorkloadWindowMaximumIncreasesAndInvariantHolds() weighted.MainProtectedMaximum.Should().BeGreaterThanOrEqualTo(0); } + [Fact] + public void WhenWeightedItemEvictedRemovedEventFires() + { + var removed = new List>(); + var cache = new ConcurrentLfuBuilder() + .WithCapacity(100) + .WithConcurrencyLevel(1) + .WithWeigher(new ValueWeigher()) + .WithEvents() + .Build(); + cache.Events.Value.ItemRemoved += (s, e) => removed.Add(e); + + // weight 30 each, total 300 exceeds the weight capacity of 100 + for (int i = 0; i < 10; i++) + { + cache.GetOrAdd(i, k => 30); + } + + var weighted = cache as WeightedConcurrentLfu, WeightedAccessOrderPolicy>>; + weighted!.DoMaintenance(); + + removed.Should().NotBeEmpty(); + removed.Should().OnlyContain(e => e.Reason == ItemRemovedReason.Evicted); + } + + [Fact] + public void WhenWeightedWithExpiryTimeToExpireIsReturned() + { + var cache = new ConcurrentLfuBuilder() + .WithCapacity(100) + .WithWeigher(new ValueWeigher()) + .WithExpireAfterWrite(TimeSpan.FromMinutes(10)) + .Build(); + + cache.GetOrAdd(1, k => 10); + + cache.Policy.ExpireAfterWrite.HasValue.Should().BeTrue(); + cache.Policy.ExpireAfterWrite.Value.TimeToLive.Should().Be(TimeSpan.FromMinutes(10)); + } + private sealed class ValueWeigher : IWeigher { public int Weigh(int key, int value) => value; diff --git a/BitFaster.Caching/Lfu/ConcurrentLfuBuilder.cs b/BitFaster.Caching/Lfu/ConcurrentLfuBuilder.cs index 4fbdeed2..bae5c85c 100644 --- a/BitFaster.Caching/Lfu/ConcurrentLfuBuilder.cs +++ b/BitFaster.Caching/Lfu/ConcurrentLfuBuilder.cs @@ -112,13 +112,29 @@ internal static ICache Create(LfuInfo info) private static ICache CreateWeighted(LfuInfo info, IWeigher weigher, IExpiryCalculator? expiry) where K : notnull { - // Weighted eviction without expiry or events. - // Weighted eviction composed with expiry or events is wired up in a later phase. - if (info.TimeToExpireAfterWrite.HasValue || info.TimeToExpireAfterAccess.HasValue || expiry != null || info.WithEvents) - Throw.InvalidOp("Weighted eviction is not yet supported in combination with expiry or events."); + IExpiryCalculator? calculator = + info.TimeToExpireAfterWrite.HasValue ? new ExpireAfterWrite(info.TimeToExpireAfterWrite.Value) + : info.TimeToExpireAfterAccess.HasValue ? new ExpireAfterAccess(info.TimeToExpireAfterAccess.Value) + : expiry; - return new FastConcurrentLfu, WeightedAccessOrderPolicy>>( - info.ConcurrencyLevel, info.Capacity, info.Scheduler, info.KeyComparer, new WeightedAccessOrderPolicy>(weigher)); + return (calculator != null, info.WithEvents) switch + { + // weighted, no expiry, no events + (false, false) => new FastConcurrentLfu, WeightedAccessOrderPolicy>>( + info.ConcurrencyLevel, info.Capacity, info.Scheduler, info.KeyComparer, new WeightedAccessOrderPolicy>(weigher)), + + // weighted, time expiry, no events + (true, false) => new FastConcurrentLfu, WeightedExpireAfterPolicy>>( + info.ConcurrencyLevel, info.Capacity, info.Scheduler, info.KeyComparer, new WeightedExpireAfterPolicy>(weigher, calculator!)), + + // weighted, no expiry, with events + (false, true) => new WeightedConcurrentLfu, WeightedAccessOrderPolicy>>( + info.ConcurrencyLevel, info.Capacity, info.Scheduler, info.KeyComparer, new WeightedAccessOrderPolicy>(weigher)), + + // weighted, time expiry, with events + (true, true) => new WeightedConcurrentLfu, WeightedExpireAfterPolicy>>( + info.ConcurrencyLevel, info.Capacity, info.Scheduler, info.KeyComparer, new WeightedExpireAfterPolicy>(weigher, calculator!)), + }; } } } diff --git a/BitFaster.Caching/Lfu/FastConcurrentLfu.cs b/BitFaster.Caching/Lfu/FastConcurrentLfu.cs index 372b7097..fbda67cb 100644 --- a/BitFaster.Caching/Lfu/FastConcurrentLfu.cs +++ b/BitFaster.Caching/Lfu/FastConcurrentLfu.cs @@ -87,14 +87,12 @@ private void DrainBuffers() /// public CachePolicy Policy => CreatePolicy(); - private static readonly bool IsExpireAfterPolicy = typeof(P) == typeof(ExpireAfterPolicy>); - private CachePolicy CreatePolicy() { - if (IsExpireAfterPolicy) - { - var calc = Unsafe.As>>(ref core.policy).ExpiryCalculator; + var calc = core.policy.ExpiryCalculator; + if (calc != null) + { var afterWrite = Optional.None(); var afterAccess = Optional.None(); var afterCustom = Optional.None(); @@ -122,17 +120,12 @@ TimeSpan ITimePolicy.TimeToLive { get { - if (IsExpireAfterPolicy) + return core.policy.ExpiryCalculator switch { - return Unsafe.As>>(ref core.policy).ExpiryCalculator switch - { - ExpireAfterAccess aa => aa.TimeToExpire, - ExpireAfterWrite aw => aw.TimeToExpire, - _ => TimeSpan.Zero, - }; - } - - return TimeSpan.Zero; + ExpireAfterAccess aa => aa.TimeToExpire, + ExpireAfterWrite aw => aw.TimeToExpire, + _ => TimeSpan.Zero, + }; } } @@ -141,7 +134,7 @@ TimeSpan ITimePolicy.TimeToLive /// public bool TryGetTimeToExpire(K1 key, out TimeSpan timeToExpire) { - if (IsExpireAfterPolicy && key is K k && core.TryGetNode(k, out N? node) && node is TimeOrderNode timeNode) + if (core.policy.ExpiryCalculator != null && key is K k && core.TryGetNode(k, out N? node) && node is TimeOrderNode timeNode) { var tte = new Duration(timeNode.GetTimestamp()) - Duration.SinceEpoch(); timeToExpire = tte.ToTimeSpan(); diff --git a/BitFaster.Caching/Lfu/NodePolicy.cs b/BitFaster.Caching/Lfu/NodePolicy.cs index b5200d6c..91d74ef2 100644 --- a/BitFaster.Caching/Lfu/NodePolicy.cs +++ b/BitFaster.Caching/Lfu/NodePolicy.cs @@ -24,6 +24,10 @@ internal interface INodePolicy int GetWeight(LfuNode node); int GetPolicyWeight(LfuNode node); void SetPolicyWeight(LfuNode node, int weight); + + // The expiry calculator for time-based policies, else null. Enables wrappers to expose the + // time policy without depending on the concrete policy type. + IExpiryCalculator? ExpiryCalculator { get; } } internal struct AccessOrderPolicy : INodePolicy, E> @@ -84,6 +88,8 @@ public void ExpireEntries

(ref ConcurrentLfuCore, public void SetPolicyWeight(LfuNode node, int weight) { } + + public IExpiryCalculator? ExpiryCalculator => null; } internal struct ExpireAfterPolicy : INodePolicy, E> diff --git a/BitFaster.Caching/Lfu/WeightedConcurrentLfu.cs b/BitFaster.Caching/Lfu/WeightedConcurrentLfu.cs new file mode 100644 index 00000000..d8e2cf6c --- /dev/null +++ b/BitFaster.Caching/Lfu/WeightedConcurrentLfu.cs @@ -0,0 +1,305 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using BitFaster.Caching.Lru; +using BitFaster.Caching.Scheduler; + +namespace BitFaster.Caching.Lfu +{ + // Weighted LFU with events. Provided as a generic wrapper around ConcurrentLfuCore to support both + // weighted access-order and weighted time-order node policies while exposing the events API. + internal sealed class WeightedConcurrentLfu : ICacheExt, IAsyncCacheExt, IBoundedPolicy, ITimePolicy, IDiscreteTimePolicy + where K : notnull + where N : LfuNode + where P : struct, INodePolicy> + { + // Note: for performance reasons this is a mutable struct, it cannot be readonly. + private ConcurrentLfuCore> core; + + public WeightedConcurrentLfu(int concurrencyLevel, int capacity, IScheduler scheduler, IEqualityComparer comparer, P nodePolicy) + { + EventPolicy eventPolicy = default; + eventPolicy.SetEventSource(this); + this.core = new(concurrencyLevel, capacity, scheduler, comparer, () => this.DrainBuffers(), nodePolicy, eventPolicy); + } + + // structs cannot declare self referencing lambda functions, therefore pass this in from the ctor + private void DrainBuffers() + { + this.core.DrainBuffers(); + } + + /// + public int Count => core.Count; + + /// + public Optional Metrics => core.Metrics; + + /// + public Optional> Events => new(new Proxy(this)); + + internal ref EventPolicy EventPolicyRef => ref this.core.eventPolicy; + + /// + public CachePolicy Policy => CreatePolicy(); + + /// + public ICollection Keys => core.Keys; + +#if NET9_0_OR_GREATER + /// + public IEqualityComparer Comparer => this.core.Comparer; +#endif + + /// + public int Capacity => core.Capacity; + + /// + public IScheduler Scheduler => core.Scheduler; + + public void DoMaintenance() + { + core.DoMaintenance(); + } + + /// + public void AddOrUpdate(K key, V value) + { + core.AddOrUpdate(key, value); + } + + /// + public void Clear() + { + core.Clear(); + } + + /// + public V GetOrAdd(K key, Func valueFactory) + { + return core.GetOrAdd(key, valueFactory); + } + + /// + public V GetOrAdd(K key, Func valueFactory, TArg factoryArgument) +#if NET9_0_OR_GREATER + where TArg : allows ref struct +#endif + { + return core.GetOrAdd(key, valueFactory, factoryArgument); + } + + /// + public ValueTask GetOrAddAsync(K key, Func> valueFactory) + { + return core.GetOrAddAsync(key, valueFactory); + } + + /// + public ValueTask GetOrAddAsync(K key, Func> valueFactory, TArg factoryArgument) + { + return core.GetOrAddAsync(key, valueFactory, factoryArgument); + } + + /// + public void Trim(int itemCount) + { + core.Trim(itemCount); + } + + /// + public bool TryGet(K key, [MaybeNullWhen(false)] out V value) + { + return core.TryGet(key, out value); + } + + /// + public bool TryRemove(K key) + { + return core.TryRemove(key); + } + + ///

+ /// Attempts to remove the specified key value pair. + /// + /// The item to remove. + /// true if the item was removed successfully; otherwise, false. + public bool TryRemove(KeyValuePair item) + { + return core.TryRemove(item); + } + + /// + /// Attempts to remove and return the value that has the specified key. + /// + /// The key of the element to remove. + /// When this method returns, contains the object removed, or the default value of the value type if key does not exist. + /// true if the object was removed successfully; otherwise, false. + public bool TryRemove(K key, [MaybeNullWhen(false)] out V value) + { + return core.TryRemove(key, out value); + } + + /// + public bool TryUpdate(K key, V value) + { + return core.TryUpdate(key, value); + } + + /// + public IEnumerator> GetEnumerator() + { + return core.GetEnumerator(); + } + + /// + IEnumerator IEnumerable.GetEnumerator() + { + return core.GetEnumerator(); + } + +#if NET9_0_OR_GREATER + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public IAlternateLookup GetAlternateLookup() + where TAlternateKey : notnull, allows ref struct + { + return core.GetAlternateLookup(); + } + + /// + public bool TryGetAlternateLookup([MaybeNullWhen(false)] out IAlternateLookup lookup) + where TAlternateKey : notnull, allows ref struct + { + return core.TryGetAlternateLookup(out lookup); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public IAsyncAlternateLookup GetAsyncAlternateLookup() + where TAlternateKey : notnull, allows ref struct + { + return core.GetAsyncAlternateLookup(); + } + + /// + public bool TryGetAsyncAlternateLookup([MaybeNullWhen(false)] out IAsyncAlternateLookup lookup) + where TAlternateKey : notnull, allows ref struct + { + return core.TryGetAsyncAlternateLookup(out lookup); + } +#endif + + private CachePolicy CreatePolicy() + { + var calc = core.policy.ExpiryCalculator; + + if (calc != null) + { + var afterWrite = Optional.None(); + var afterAccess = Optional.None(); + var afterCustom = Optional.None(); + + switch (calc) + { + case ExpireAfterAccess: + afterAccess = new Optional(this); + break; + case ExpireAfterWrite: + afterWrite = new Optional(this); + break; + default: + afterCustom = new Optional(this); + break; + } + + return new CachePolicy(new Optional(this), afterWrite, afterAccess, afterCustom); + } + + return core.Policy; + } + + TimeSpan ITimePolicy.TimeToLive + { + get + { + return core.policy.ExpiryCalculator switch + { + ExpireAfterAccess aa => aa.TimeToExpire, + ExpireAfterWrite aw => aw.TimeToExpire, + _ => TimeSpan.Zero, + }; + } + } + + void ITimePolicy.TrimExpired() => DoMaintenance(); + + void IDiscreteTimePolicy.TrimExpired() => DoMaintenance(); + + /// + public bool TryGetTimeToExpire(K1 key, out TimeSpan timeToExpire) + { + if (core.policy.ExpiryCalculator != null && key is K k && core.TryGetNode(k, out N? node) && node is TimeOrderNode timeNode) + { + var tte = new Duration(timeNode.GetTimestamp()) - Duration.SinceEpoch(); + timeToExpire = tte.ToTimeSpan(); + return true; + } + + timeToExpire = default; + return false; + } + + // To get JIT optimizations, policies must be structs. + // If the structs are returned directly via properties, they will be copied. Since + // eventPolicy is a mutable struct, copy is bad since changes are lost. + // Hence it is returned by ref and mutated via Proxy. + private class Proxy : ICacheEvents + { + private readonly WeightedConcurrentLfu lfu; + + public Proxy(WeightedConcurrentLfu lfu) + { + this.lfu = lfu; + } + + public event EventHandler> ItemRemoved + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + add + { + ref var policy = ref this.lfu.EventPolicyRef; + policy.ItemRemoved += value; + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + remove + { + ref var policy = ref this.lfu.EventPolicyRef; + policy.ItemRemoved -= value; + } + } + + // backcompat: remove conditional compile +#if NETCOREAPP3_0_OR_GREATER + public event EventHandler> ItemUpdated + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + add + { + ref var policy = ref this.lfu.EventPolicyRef; + policy.ItemUpdated += value; + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] + remove + { + ref var policy = ref this.lfu.EventPolicyRef; + policy.ItemUpdated -= value; + } + } +#endif + } + } +} diff --git a/BitFaster.Caching/Lfu/WeightedNodePolicy.cs b/BitFaster.Caching/Lfu/WeightedNodePolicy.cs index 4c4a40c5..305e36e5 100644 --- a/BitFaster.Caching/Lfu/WeightedNodePolicy.cs +++ b/BitFaster.Caching/Lfu/WeightedNodePolicy.cs @@ -72,6 +72,8 @@ public void ExpireEntries

(ref ConcurrentLfuCore node, int weight) => ((WeightedAccessOrderNode)node).PolicyWeight = weight; + public IExpiryCalculator? ExpiryCalculator => null; + [MethodImpl(MethodImplOptions.AggressiveInlining)] private int Weigh(K key, V value) { From 3a513308e5208432178b3186410f87da7b041172 Mon Sep 17 00:00:00 2001 From: Alex Peck Date: Mon, 22 Jun 2026 13:17:15 -0700 Subject: [PATCH 6/8] Weighted LFU phase 6: concurrency soak test and docs 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> --- .../Lfu/ConcurrentLfuSoakTests.cs | 40 +++++++++++++++++++ .../Lfu/WeightedConcurrentLfu.cs | 2 + README.md | 18 +++++++++ 3 files changed, 60 insertions(+) diff --git a/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuSoakTests.cs b/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuSoakTests.cs index 69a560d7..a24c88f5 100644 --- a/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuSoakTests.cs +++ b/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuSoakTests.cs @@ -254,6 +254,46 @@ await Threaded.Run(threads, () => await RunIntegrityCheckAsync(lfu, iteration); } + [Theory] + [Repeat(soakIterations)] + public async Task WhenConcurrentWeightedGetUpdateRemoveCacheEndsInConsistentState() + { + var cache = new ConcurrentLfuBuilder() + .WithConcurrencyLevel(threads) + .WithCapacity(9) + .WithScheduler(new BackgroundThreadScheduler()) + .WithWeigher(new StringLengthWeigher()) + .WithEvents() + .Build(); + + await Threaded.Run(threads, () => + { + for (int i = 0; i < loopIterations; i++) + { + int k = i % 100; + cache.GetOrAdd(k, x => x.ToString()); + cache.TryUpdate(k, "updated"); + + if ((i & 7) == 0) + { + cache.TryRemove(k); + } + } + }); + + var weighted = (WeightedConcurrentLfu, WeightedAccessOrderPolicy>>)cache; + new ConcurrentLfuIntegrityChecker, WeightedAccessOrderPolicy>, EventPolicy>(weighted.Core).Validate(output); + + // weighted invariant: total weight is non-negative and bounded by the weight capacity + weighted.Core.WeightedSize.Should().BeGreaterThanOrEqualTo(0); + weighted.Core.WeightedSize.Should().BeLessThanOrEqualTo(weighted.Capacity); + } + + private sealed class StringLengthWeigher : IWeigher + { + public int Weigh(int key, string value) => value.Length; + } + [Theory] [Repeat(soakIterations)] public async Task WhenConcurrentGetAndRemoveKvpCacheEndsInConsistentState(int iteration) diff --git a/BitFaster.Caching/Lfu/WeightedConcurrentLfu.cs b/BitFaster.Caching/Lfu/WeightedConcurrentLfu.cs index d8e2cf6c..16e33f3c 100644 --- a/BitFaster.Caching/Lfu/WeightedConcurrentLfu.cs +++ b/BitFaster.Caching/Lfu/WeightedConcurrentLfu.cs @@ -32,6 +32,8 @@ private void DrainBuffers() this.core.DrainBuffers(); } + internal ConcurrentLfuCore> Core => core; + /// public int Count => core.Count; diff --git a/README.md b/README.md index 54be5bfe..835af08d 100644 --- a/README.md +++ b/README.md @@ -49,3 +49,21 @@ var lfu = new ConcurrentLfu(capacity); var value = lfu.GetOrAdd("key", (key) => new SomeItem(key)); ``` + +### Weighted eviction + +By default each entry counts as 1 towards the capacity. To bound the cache by a custom weight instead (for example total memory), configure a weigher using the builder. The capacity is then interpreted as the maximum total weight, and entries are evicted to keep the total weight within bounds. Weigher results must be non-negative; an entry with weight `0` does not count towards the bound, so `Count` may exceed `Capacity` when light entries are present. + +```csharp +var lfu = new ConcurrentLfuBuilder() + .WithCapacity(1_000_000) // maximum total weight + .WithWeigher(new ByteArrayWeigher()) + .Build(); + +class ByteArrayWeigher : IWeigher +{ + public int Weigh(string key, byte[] value) => value.Length; +} +``` + +`WithWeigher` composes with `WithExpireAfterWrite`/`WithExpireAfterAccess`/`WithExpireAfter` and `WithEvents`. From 2cc5081e7b52dd4a30ddd58a4e46c1c64f3f746c Mon Sep 17 00:00:00 2001 From: Alex Peck Date: Mon, 22 Jun 2026 13:38:14 -0700 Subject: [PATCH 7/8] Fix weighted soak test signature stripped by dotnet format 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.Caching.UnitTests/Lfu/ConcurrentLfuSoakTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuSoakTests.cs b/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuSoakTests.cs index a24c88f5..9b824c42 100644 --- a/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuSoakTests.cs +++ b/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuSoakTests.cs @@ -256,8 +256,10 @@ await Threaded.Run(threads, () => [Theory] [Repeat(soakIterations)] - public async Task WhenConcurrentWeightedGetUpdateRemoveCacheEndsInConsistentState() + public async Task WhenConcurrentWeightedGetUpdateRemoveCacheEndsInConsistentState(int iteration) { + this.output.WriteLine($"Weighted soak iteration {iteration}."); + var cache = new ConcurrentLfuBuilder() .WithConcurrencyLevel(threads) .WithCapacity(9) From 5c808350ab9a1262a407b1b1fb4686b39b7311c5 Mon Sep 17 00:00:00 2001 From: Alex Peck Date: Mon, 22 Jun 2026 13:50:19 -0700 Subject: [PATCH 8/8] Rename IWeigher to IWeightCalculator and Weigh to GetWeight 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> --- .../Lfu/ConcurrentLfuBuilderTests.cs | 4 ++-- .../Lfu/ConcurrentLfuCoreTests.cs | 4 ++-- .../Lfu/ConcurrentLfuSoakTests.cs | 4 ++-- .../Lfu/WeightedNodePolicyTests.cs | 6 +++--- .../{IWeigher.cs => IWeightCalculator.cs} | 4 ++-- BitFaster.Caching/Lfu/Builder/LfuInfo.cs | 8 ++++---- BitFaster.Caching/Lfu/ConcurrentLfuBuilder.cs | 6 +++--- BitFaster.Caching/Lfu/WeightedNodePolicy.cs | 16 ++++++++-------- README.md | 4 ++-- 9 files changed, 28 insertions(+), 28 deletions(-) rename BitFaster.Caching/{IWeigher.cs => IWeightCalculator.cs} (89%) diff --git a/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuBuilderTests.cs b/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuBuilderTests.cs index 1d9e881a..b614524b 100644 --- a/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuBuilderTests.cs +++ b/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuBuilderTests.cs @@ -618,9 +618,9 @@ public void WithWeigherAndExpireAfterWriteAndEventsBuildsWeightedCache() weighted.Events.HasValue.Should().BeTrue(); } - private sealed class IntValueWeigher : IWeigher + private sealed class IntValueWeigher : IWeightCalculator { - public int Weigh(int key, int value) => value; + public int GetWeight(int key, int value) => value; } } } diff --git a/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuCoreTests.cs b/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuCoreTests.cs index ae4a18c9..56c46a1a 100644 --- a/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuCoreTests.cs +++ b/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuCoreTests.cs @@ -219,9 +219,9 @@ public override void DoMaintenance(ICache cache) weighted?.DoMaintenance(); } - private sealed class UnitWeigher : IWeigher + private sealed class UnitWeigher : IWeightCalculator { - public int Weigh(K key, V value) => 1; + public int GetWeight(K key, V value) => 1; } } } diff --git a/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuSoakTests.cs b/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuSoakTests.cs index 9b824c42..9134bcbe 100644 --- a/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuSoakTests.cs +++ b/BitFaster.Caching.UnitTests/Lfu/ConcurrentLfuSoakTests.cs @@ -291,9 +291,9 @@ await Threaded.Run(threads, () => weighted.Core.WeightedSize.Should().BeLessThanOrEqualTo(weighted.Capacity); } - private sealed class StringLengthWeigher : IWeigher + private sealed class StringLengthWeigher : IWeightCalculator { - public int Weigh(int key, string value) => value.Length; + public int GetWeight(int key, string value) => value.Length; } [Theory] diff --git a/BitFaster.Caching.UnitTests/Lfu/WeightedNodePolicyTests.cs b/BitFaster.Caching.UnitTests/Lfu/WeightedNodePolicyTests.cs index 770376d6..97a1e178 100644 --- a/BitFaster.Caching.UnitTests/Lfu/WeightedNodePolicyTests.cs +++ b/BitFaster.Caching.UnitTests/Lfu/WeightedNodePolicyTests.cs @@ -18,7 +18,7 @@ public WeightedNodePolicyTests() core = CreateWeighted(Capacity, new ValueWeigher()); } - private static ConcurrentLfuCore, WeightedAccessOrderPolicy>, NoEventPolicy> CreateWeighted(int capacity, IWeigher weigher) + private static ConcurrentLfuCore, WeightedAccessOrderPolicy>, NoEventPolicy> CreateWeighted(int capacity, IWeightCalculator weigher) { var policy = new WeightedAccessOrderPolicy>(weigher); return new(1, capacity, new NullScheduler(), EqualityComparer.Default, () => { }, policy, default); @@ -207,9 +207,9 @@ public void WhenWeightedWithExpiryTimeToExpireIsReturned() cache.Policy.ExpireAfterWrite.Value.TimeToLive.Should().Be(TimeSpan.FromMinutes(10)); } - private sealed class ValueWeigher : IWeigher + private sealed class ValueWeigher : IWeightCalculator { - public int Weigh(int key, int value) => value; + public int GetWeight(int key, int value) => value; } } } diff --git a/BitFaster.Caching/IWeigher.cs b/BitFaster.Caching/IWeightCalculator.cs similarity index 89% rename from BitFaster.Caching/IWeigher.cs rename to BitFaster.Caching/IWeightCalculator.cs index a1b02a5f..94fc0df3 100644 --- a/BitFaster.Caching/IWeigher.cs +++ b/BitFaster.Caching/IWeightCalculator.cs @@ -7,7 +7,7 @@ /// /// The type of keys. /// The type of values. - public interface IWeigher + public interface IWeightCalculator { ///

/// Returns the weight of a cache entry. The weight must be non-negative. @@ -15,6 +15,6 @@ public interface IWeigher /// The key to weigh. /// The value to weigh. /// The weight of the entry. - int Weigh(K key, V value); + int GetWeight(K key, V value); } } diff --git a/BitFaster.Caching/Lfu/Builder/LfuInfo.cs b/BitFaster.Caching/Lfu/Builder/LfuInfo.cs index ed6b0911..648329a5 100644 --- a/BitFaster.Caching/Lfu/Builder/LfuInfo.cs +++ b/BitFaster.Caching/Lfu/Builder/LfuInfo.cs @@ -44,19 +44,19 @@ internal sealed class LfuInfo return e; } - public void SetWeigher(IWeigher weigher) => this.weigher = weigher; + public void SetWeigher(IWeightCalculator weigher) => this.weigher = weigher; - public IWeigher? GetWeigher() + public IWeightCalculator? GetWeigher() { if (this.weigher == null) { return null; } - var w = this.weigher as IWeigher; + var w = this.weigher as IWeightCalculator; if (w == null) - Throw.InvalidOp($"Incompatible IWeigher value generic type argument, expected {typeof(IWeigher)} but found {this.weigher.GetType()}"); + Throw.InvalidOp($"Incompatible IWeightCalculator value generic type argument, expected {typeof(IWeightCalculator)} but found {this.weigher.GetType()}"); return w; } diff --git a/BitFaster.Caching/Lfu/ConcurrentLfuBuilder.cs b/BitFaster.Caching/Lfu/ConcurrentLfuBuilder.cs index bae5c85c..0db32fe1 100644 --- a/BitFaster.Caching/Lfu/ConcurrentLfuBuilder.cs +++ b/BitFaster.Caching/Lfu/ConcurrentLfuBuilder.cs @@ -48,12 +48,12 @@ public ConcurrentLfuBuilder WithExpireAfter(IExpiryCalculator expiry /// /// Evict using a weighted size, where the weight of each item is calculated using the specified - /// IWeigher. The cache is bounded by the total weight of all items, and the capacity is interpreted + /// IWeightCalculator. The cache is bounded by the total weight of all items, and the capacity is interpreted /// as the maximum total weight. /// /// The weigher that determines the weight of each item. /// A ConcurrentLfuBuilder - public ConcurrentLfuBuilder WithWeigher(IWeigher weigher) + public ConcurrentLfuBuilder WithWeigher(IWeightCalculator weigher) { this.info.SetWeigher(weigher); return this; @@ -109,7 +109,7 @@ internal static ICache Create(LfuInfo info) }; } - private static ICache CreateWeighted(LfuInfo info, IWeigher weigher, IExpiryCalculator? expiry) + private static ICache CreateWeighted(LfuInfo info, IWeightCalculator weigher, IExpiryCalculator? expiry) where K : notnull { IExpiryCalculator? calculator = diff --git a/BitFaster.Caching/Lfu/WeightedNodePolicy.cs b/BitFaster.Caching/Lfu/WeightedNodePolicy.cs index 305e36e5..40a3db47 100644 --- a/BitFaster.Caching/Lfu/WeightedNodePolicy.cs +++ b/BitFaster.Caching/Lfu/WeightedNodePolicy.cs @@ -4,15 +4,15 @@ namespace BitFaster.Caching.Lfu { /// /// A weighted node policy for caches without expiry. Each entry is weighed using the supplied - /// , and the cache is bounded by total weight. + /// , and the cache is bounded by total weight. /// internal struct WeightedAccessOrderPolicy : INodePolicy, E> where K : notnull where E : struct, IEventPolicy { - private readonly IWeigher weigher; + private readonly IWeightCalculator weigher; - public WeightedAccessOrderPolicy(IWeigher weigher) + public WeightedAccessOrderPolicy(IWeightCalculator weigher) { this.weigher = weigher; } @@ -77,7 +77,7 @@ public void ExpireEntries

(ref ConcurrentLfuCore /// A weighted node policy for caches with expiry. Each entry is weighed using the supplied - /// and expires using the supplied . + /// and expires using the supplied . ///

internal struct WeightedExpireAfterPolicy : INodePolicy, E> where K : notnull where E : struct, IEventPolicy { - private readonly IWeigher weigher; + private readonly IWeightCalculator weigher; private readonly IExpiryCalculator expiryCalculator; private readonly TimerWheel wheel; private Duration current; - public WeightedExpireAfterPolicy(IWeigher weigher, IExpiryCalculator expiryCalculator) + public WeightedExpireAfterPolicy(IWeightCalculator weigher, IExpiryCalculator expiryCalculator) { this.wheel = new TimerWheel(); this.weigher = weigher; @@ -185,7 +185,7 @@ public void ExpireEntries

(ref ConcurrentLfuCore() .WithWeigher(new ByteArrayWeigher()) .Build(); -class ByteArrayWeigher : IWeigher +class ByteArrayWeigher : IWeightCalculator { - public int Weigh(string key, byte[] value) => value.Length; + public int GetWeight(string key, byte[] value) => value.Length; } ```