Documentation
¶
Overview ¶
Package ascache is a cache that chooses its own eviction policy.
Choosing a replacement policy normally means guessing which one suits your traffic, and the cost of guessing wrong is large: on a cyclic access pattern just larger than the cache, LRU serves a 0% hit rate where W-TinyLFU serves 92%. This library removes the guess. It runs candidate policies side by side, measures them against your real traffic, and either tells you which one wins or switches to it for you.
How it works ¶
One policy is active and serves real data. The others are shadows: they receive the same key stream with zero values, purely so their hit rates can be compared. Every epoch each policy reports what it measured to a Bandit, which picks the policy for the next epoch.
cache, err := ascache.NewAdaptiveCache(
[]ascache.Policy[string, int]{lru, twoQ, tinyLFU},
myBandit,
&ascache.Settings{EpochDuration: time.Minute},
)
defer cache.Close()
The API is a superset of hashicorp/golang-lru/v2, so an existing cache can be swapped for one of these without changing call sites. AdaptiveCache.Stats, AdaptiveCache.Advice, AdaptiveCache.ActivePolicy and AdaptiveCache.Close are the additions.
Ready-made policies live in companion modules, so the core has no dependencies: github.com/sshaplygin/as-cache/policies for LRU, 2Q, Random and TTL, .../policies/arc for ARC, .../policies/tinylfu for W-TinyLFU.
Start by observing ¶
The lowest-risk way to adopt this is not to let it switch anything. With Settings.ObserveOnly the cache behaves exactly like the first policy it was given, while every other policy is measured in the background, and AdaptiveCache.Advice reports what it found. No bandit is needed in this mode.
cache, _ := ascache.NewAdaptiveCache(policies, nil, &ascache.Settings{
EpochDuration: time.Minute,
ObserveOnly: true,
})
// ... later ...
fmt.Println(cache.Advice())
Cost ¶
Shadow policies hold keys and eviction bookkeeping but never values, so they cost far less than a full copy: six policies measure at 2.65x the memory of one, and 1.32x with Settings.ShadowSampleRate set. Sampling has shadows track a deterministic fraction of the keyspace, which stops per-operation cost scaling with the number of policies.
What to expect ¶
Adaptive selection reliably beats the worst policy you might have picked and lands close to the best. On published traces it comes within about a point of the best fixed policy and occasionally beats it, without being told in advance which that is. It will not dramatically outperform a policy you have already measured and know suits your traffic.
Epoch duration is the setting that matters most: too short and the cache spends its time migrating between policies rather than serving. See the README for measurements and configuration guidance.
Index ¶
- Constants
- Variables
- type AdaptiveCache
- func (c *AdaptiveCache[K, V]) ActivePolicy() PolicyType
- func (c *AdaptiveCache[K, V]) Add(key K, value V) bool
- func (c *AdaptiveCache[K, V]) Advice() Advice
- func (c *AdaptiveCache[K, V]) Close() error
- func (c *AdaptiveCache[K, V]) Contains(key K) bool
- func (c *AdaptiveCache[K, V]) Get(key K) (V, bool)
- func (c *AdaptiveCache[K, V]) Keys() []K
- func (c *AdaptiveCache[K, V]) Len() int
- func (c *AdaptiveCache[K, V]) Peek(key K) (value V, ok bool)
- func (c *AdaptiveCache[K, V]) Purge()
- func (c *AdaptiveCache[K, V]) Remove(key K) bool
- func (c *AdaptiveCache[K, V]) Resize(size int) int
- func (c *AdaptiveCache[K, V]) Stats() GlobalStats
- func (c *AdaptiveCache[K, V]) Values() []V
- type Advice
- type Bandit
- type CacheStats
- type CacheWrapper
- func (c *CacheWrapper[K, V]) Cap() int
- func (c *CacheWrapper[K, V]) Get(key K) (value V, ok bool)
- func (c *CacheWrapper[K, V]) GetStats() PolicyStats
- func (c *CacheWrapper[K, V]) GetType() PolicyType
- func (c *CacheWrapper[K, V]) Name() string
- func (c *CacheWrapper[K, V]) ResetStats()
- func (c *CacheWrapper[K, V]) Resize(size int) int
- type Cacher
- type EpochBandit
- type EpochReport
- type GlobalStats
- type MigrationStrategy
- type Policy
- type PolicyReport
- type PolicyStats
- type PolicyType
- type Settings
- type ShadowStats
Constants ¶
const DefaultMinShadowCapacity = 256
DefaultMinShadowCapacity is the miniature capacity floor applied when Settings.MinShadowCapacity is zero.
Variables ¶
var ErrDuplicatePolicy = errors.New("duplicate policy type")
ErrDuplicatePolicy is returned by NewAdaptiveCache when two policies report the same PolicyType.
var ErrEmptyPolicies = errors.New("must provide non zero policies size")
ErrEmptyPolicies is returned by NewAdaptiveCache when the policies slice is nil or empty.
var ErrInvalidEpochDuration = errors.New(
"epoch duration must be positive, or epoch requests must be set",
)
ErrInvalidEpochDuration is returned by NewAdaptiveCache when neither epoch clock is set: Settings.EpochDuration is zero or negative and Settings.EpochRequests is zero, so nothing would ever end an epoch.
var ErrInvalidEpochRequests = errors.New("epoch requests must not be negative")
ErrInvalidEpochRequests is returned by NewAdaptiveCache when Settings.EpochRequests is negative.
var ErrNilBandit = errors.New("bandit must not be nil")
ErrNilBandit is returned by NewAdaptiveCache when the bandit is nil.
var ErrNilPolicy = errors.New("policy must not be nil")
ErrNilPolicy is returned by NewAdaptiveCache when one of the provided policies is nil.
var ErrNilSettings = errors.New("settings must not be nil")
ErrNilSettings is returned by NewAdaptiveCache when settings is nil.
Functions ¶
This section is empty.
Types ¶
type AdaptiveCache ¶
type AdaptiveCache[K comparable, V any] struct { // contains filtered or unexported fields }
AdaptiveCache is a cache that automatically selects the best replacement policy at runtime using a Multi-Armed Bandit algorithm.
func NewAdaptiveCache ¶
func NewAdaptiveCache[K comparable, V any]( policies []Policy[K, V], bandit Bandit, settings *Settings, ) (*AdaptiveCache[K, V], error)
NewAdaptiveCache validates its inputs and starts the background epoch goroutine. Callers must call Close to stop that goroutine.
func (*AdaptiveCache[K, V]) ActivePolicy ¶
func (c *AdaptiveCache[K, V]) ActivePolicy() PolicyType
ActivePolicy returns the PolicyType that is currently serving cache operations. It is safe to call concurrently.
func (*AdaptiveCache[K, V]) Add ¶
func (c *AdaptiveCache[K, V]) Add(key K, value V) bool
func (*AdaptiveCache[K, V]) Advice ¶
func (c *AdaptiveCache[K, V]) Advice() Advice
Advice reports which policy has served this cache's traffic best.
It is safe to call at any time and does not disturb measurement. The advice is only as good as the traffic behind it: a cache that has run for a few epochs, or one whose policies are within noise of each other, has nothing useful to say, and Epochs is included so a caller can tell.
func (*AdaptiveCache[K, V]) Close ¶
func (c *AdaptiveCache[K, V]) Close() error
Close stops the background epoch goroutine and waits for it to exit. It is idempotent and safe to call concurrently; every call returns nil after the goroutine has stopped.
func (*AdaptiveCache[K, V]) Contains ¶
func (c *AdaptiveCache[K, V]) Contains(key K) bool
func (*AdaptiveCache[K, V]) Get ¶
func (c *AdaptiveCache[K, V]) Get(key K) (V, bool)
Get returns the value stored for key by the active policy, feeding the same lookup to every shadow policy that samples the key.
When Settings.EpochRequests is set, the call that completes an epoch runs it here, after every lock this method took has been released - runEpoch needs the write lock, and a Get still holding the read lock would deadlock against it.
func (*AdaptiveCache[K, V]) Keys ¶
func (c *AdaptiveCache[K, V]) Keys() []K
func (*AdaptiveCache[K, V]) Len ¶
func (c *AdaptiveCache[K, V]) Len() int
func (*AdaptiveCache[K, V]) Peek ¶
func (c *AdaptiveCache[K, V]) Peek(key K) (value V, ok bool)
func (*AdaptiveCache[K, V]) Purge ¶
func (c *AdaptiveCache[K, V]) Purge()
func (*AdaptiveCache[K, V]) Remove ¶
func (c *AdaptiveCache[K, V]) Remove(key K) bool
func (*AdaptiveCache[K, V]) Resize ¶
func (c *AdaptiveCache[K, V]) Resize(size int) int
Resize sets the cache's capacity to size and returns the total number of entries evicted across all policies. Shadow policies are resized to the miniature capacity that corresponds to size rather than to size itself, so they stay faithful simulations of a cache of the requested capacity.
The sample rate itself is fixed for the life of the cache: changing it would change which keys are sampled, invalidating every shadow's accumulated state. The miniature capacity therefore follows the rate directly here, without the MinShadowCapacity floor that construction applies - see scaledCapacity.
func (*AdaptiveCache[K, V]) Stats ¶
func (c *AdaptiveCache[K, V]) Stats() GlobalStats
Stats returns the cumulative hits and misses served by the cache: totals folded up to the last reporting epoch (globalStats) plus the active policy's counters accumulated since then.
func (*AdaptiveCache[K, V]) Values ¶
func (c *AdaptiveCache[K, V]) Values() []V
type Advice ¶
type Advice struct {
// Epochs is how many epochs actually measured something and fed this
// advice. Advice from a handful of epochs is not worth acting on. It
// counts reporting epochs rather than elapsed ticks, so an epoch the
// capacity gate skipped is not counted as evidence.
//
// It resets to nothing for a policy that changes role, so shortly after a
// switch the advice is deliberately thin rather than confidently stale.
Epochs int64
// Active is the policy serving requests.
Active PolicyType
// Best is the policy with the highest measured hit rate.
Best PolicyType
// Improvement is how many percentage points Best beats Active by. It is
// zero when they are the same policy.
Improvement float64
// Sampled reports whether the measurements come from a sampled substream,
// in which case the rates are estimates.
Sampled bool
// SampleRate is the fraction of the keyspace measured, 1 when sampling is
// off.
SampleRate float64
// Reports holds every policy, best hit rate first.
Reports []PolicyReport
}
Advice is what the cache has learned about which policy suits the traffic it has seen.
It is the answer to a question that is otherwise expensive to ask: not "is my cache fast" but "would a different eviction policy serve my traffic better, and by how much". Running in ObserveOnly mode makes that answerable without ever changing what the cache does.
type Bandit ¶
type Bandit interface {
// RecordStats delivers one policy's performance report. On every
// reporting epoch each policy reports — the active policy included — so
// implementations receive a full set of arms and must not synthesize
// stats for the active arm themselves. When
// Settings.EvictPartialCapacityFilling is false, epochs where the active
// policy is not yet full skip reporting entirely; counters then
// accumulate and the next report spans the skipped epochs.
//
// It is not called on a bandit that also implements EpochBandit; that
// interface's RecordEpoch replaces it.
RecordStats(stats ShadowStats)
// SelectPolicy asks the bandit to choose which policy should become the
// active one for the next epoch.
//
// Returning a policy the cache was not built with - Undefined included,
// which is the natural answer from a bandit that has not yet formed an
// opinion - is not an error and means no change.
SelectPolicy() PolicyType
}
Bandit chooses which policy should be active, given what each has measured.
This package ships no implementation, because the choice of strategy is the interesting part and depends on how quickly the traffic changes. Ready-made ones live in the companion github.com/sshaplygin/as-cache/bandit module: a local Thompson sampler, and a distributed bandit that pools evidence across a fleet through Valkey or Redis.
Implementations must not block ¶
Both methods are called from the epoch goroutine while it holds the cache's write lock, so for as long as either runs, every Get and Add in the process is stalled behind it. A bandit that talks to the network, reads a file, or waits on a channel must do it on its own goroutine and have these methods only exchange buffered state. This is not a performance guideline: Go's RWMutex queues new readers behind a waiting writer, so a multi-second timeout here is a multi-second outage for the whole cache.
type CacheStats ¶
type CacheStats interface {
GetStats() PolicyStats
ResetStats()
}
CacheStats is the hit/miss accounting a policy exposes so its performance can be compared with the other arms.
type CacheWrapper ¶
type CacheWrapper[K comparable, V any] struct { Cacher[K, V] // contains filtered or unexported fields }
func NewCache ¶
func NewCache[K comparable, V any]( cache Cacher[K, V], policy PolicyType, size int, ) *CacheWrapper[K, V]
func (*CacheWrapper[K, V]) Cap ¶
func (c *CacheWrapper[K, V]) Cap() int
func (*CacheWrapper[K, V]) Get ¶
func (c *CacheWrapper[K, V]) Get(key K) (value V, ok bool)
func (*CacheWrapper[K, V]) GetStats ¶
func (c *CacheWrapper[K, V]) GetStats() PolicyStats
func (*CacheWrapper[K, V]) GetType ¶
func (c *CacheWrapper[K, V]) GetType() PolicyType
func (*CacheWrapper[K, V]) Name ¶
func (c *CacheWrapper[K, V]) Name() string
func (*CacheWrapper[K, V]) ResetStats ¶
func (c *CacheWrapper[K, V]) ResetStats()
func (*CacheWrapper[K, V]) Resize ¶
func (c *CacheWrapper[K, V]) Resize(size int) int
Resize changes the wrapped cache's capacity and keeps Cap in step with it. The embedded Cacher's Resize would otherwise be promoted directly, leaving Cap reporting the capacity the wrapper was built with forever.
type Cacher ¶
type Cacher[K comparable, V any] interface { Add(key K, value V) (evicted bool) Contains(key K) bool Get(key K) (value V, ok bool) Keys() []K Len() int Peek(key K) (value V, ok bool) Purge() Remove(key K) (present bool) Resize(size int) (evicted int) Values() []V }
Cacher is the cache interface an eviction policy must satisfy to be used as an arm. It is deliberately identical to the method set of hashicorp/golang-lru/v2, so an existing cache is usually already a Cacher, and so an AdaptiveCache is a drop-in replacement for one.
type EpochBandit ¶ added in v0.2.0
type EpochBandit interface {
Bandit
// RecordEpoch delivers every arm's measurements for one reporting epoch.
// The report and its Stats slice are freshly allocated for each call and
// are never reused by the cache, so an implementation may retain them.
//
// The same non-blocking rule applies as to the rest of Bandit: this runs
// under the cache's write lock.
RecordEpoch(report EpochReport)
}
EpochBandit is an optional extension of Bandit for implementations that need to see a reporting epoch as a whole rather than as a sequence of per-policy calls.
RecordStats hands over one arm at a time with no epoch identifier, no marker for where one epoch ends and the next begins, and no indication of which arm was serving traffic. That is enough for a bandit that only accumulates posteriors, and not enough for one that has to publish an epoch's evidence somewhere else - which needs to know what to key it by, when the epoch is complete, and that the active arm's numbers were measured at full capacity while every shadow's were measured on a miniature.
A Bandit that implements this receives exactly one RecordEpoch call per reporting epoch and no RecordStats calls at all.
type EpochReport ¶ added in v0.2.0
type EpochReport struct {
// EpochID is the cache's epoch counter at the time of the report. It
// counts ticks, including those the EvictPartialCapacityFilling gate
// skipped, so consecutive reports are not necessarily consecutive IDs.
// It is process-local: two caches in two processes share no origin, so it
// orders one cache's reports and nothing more.
EpochID int64
// Active is the policy that was serving traffic during the epoch. Its
// counts were measured at full capacity over the sampled substream; every
// other arm's were measured on a miniature of that capacity. The rates are
// comparable by construction, but not identically measured, and pooling
// one arm's active-role numbers with another's shadow-role numbers gives
// the active one a systematic advantage.
Active PolicyType
// Stats holds one entry per arm, ordered by PolicyType so the report is
// reproducible, and carries the same counts RecordStats would have
// delivered individually.
Stats []ShadowStats
// Capacity is the nominal capacity of the active policy: the size the
// cache actually serves at.
//
// It is reported because a hit rate only means something alongside the
// capacity it was measured at. A bandit pooling evidence from several
// caches has to refuse to pool measurements taken at different sizes -
// otherwise it averages a 1000-entry cache's hit rate with a 100-entry
// cache's and acts on a number that describes neither.
Capacity int
// SampleRate is the fraction of the keyspace the measurements cover, 1
// when Settings.ShadowSampleRate is off. Like Capacity, it is part of what
// makes two caches' numbers comparable: shadows run as miniatures scaled
// to this rate, so two caches sampling differently are simulating
// different things.
SampleRate float64
}
EpochReport is one reporting epoch's complete set of measurements, delivered to a bandit that implements EpochBandit.
It exists because the per-arm ShadowStats stream loses three things a bandit coordinating with anything outside the process needs: which epoch the numbers belong to, where the epoch ends, and which arm was active.
type GlobalStats ¶
GlobalStats holds aggregate hit/miss statistics exposed to callers.
type MigrationStrategy ¶
type MigrationStrategy uint
MigrationStrategy controls how key/value pairs are transferred when the active policy changes.
const ( // MigrationCold starts the new active policy from an empty state. This is // the simplest strategy but causes a temporary cache-miss spike after every // policy switch. MigrationCold MigrationStrategy = iota + 1 // MigrationWarm copies all key/value pairs from the old active policy into // the new active policy at switch time. Shadow zero-value entries in the // target policy are purged first so that only real values are served. MigrationWarm // MigrationGradual lazily drains the old active policy into the new one. // During the window each Get() promotes the requested key from the old // policy into the new active — when it is still eligible (not overwritten // by a shadow Add, already promoted, or evicted from the source) — before // the lookup is counted, so served requests register as hits; each Add() // call migrates at most one additional key. While the window is open, // Get() takes the write lock, serializing reads. // // The window closes when no eligible keys remain, on Purge(), on the next // policy switch, and in any case at the next epoch boundary - a workload // that simply stops touching the pending keys must not leave it open // forever, holding the source at full capacity with its values retained. MigrationGradual )
type Policy ¶
type Policy[K comparable, V any] interface { Cacher[K, V] // Cap reports the capacity. hashicorp/golang-lru/v2 has no such method, // but the adaptive layer needs it: shadow policies run at a reduced // capacity and are restored to their full one when promoted. Cap() int CacheStats GetType() PolicyType }
Policy is a cache that can serve as one arm of an AdaptiveCache: a Cacher that also reports its capacity, its measurements, and which policy it is.
type PolicyReport ¶
type PolicyReport struct {
// Policy is the arm this report describes.
Policy PolicyType
// Hits and Misses are the requests measured for this policy in its current
// role - as the active policy, or as a shadow - since it last changed
// role. Under ShadowSampleRate these are counts over the sampled
// substream, not over all traffic: the rate is meaningful, the magnitude
// is a sample.
Hits int64
Misses int64
// Active reports whether this policy was serving requests when the report
// was taken.
Active bool
}
PolicyReport is one policy's measured performance over the cache's lifetime.
func (PolicyReport) HitRate ¶
func (r PolicyReport) HitRate() float64
HitRate returns the fraction of measured requests this policy served, or 0 when it has measured nothing.
type PolicyStats ¶
type PolicyType ¶
type PolicyType uint
PolicyType identifies a cache replacement policy.
const ( // Undefined is the zero value and names no policy. Undefined PolicyType = iota // LRU evicts the least recently used entry. LRU // LFU evicts the least frequently used entry. LFU // TwoQueue evicts using the 2Q algorithm, which keeps a small recent-access // queue in front of a frequently-accessed queue so a scan cannot flush the // working set. TwoQueue // ARC evicts using Adaptive Replacement Cache, which balances recency // against frequency on its own. The algorithm is patented by IBM, so its // adapter lives in a separate module that nothing else depends on. ARC // Random evicts an arbitrary entry. It is a useful control arm: a policy // that cannot beat random on a workload is not earning its bookkeeping. Random // TTL evicts by expiry as well as by recency. TTL // TinyLFU evicts using the W-TinyLFU family, which gates admission on a // frequency sketch so a new key must earn its place against the entry it // would displace. It is the strongest general-purpose baseline in wide // use, and the one an adaptive cache has to beat to justify itself. TinyLFU )
func (PolicyType) String ¶
func (i PolicyType) String() string
type Settings ¶
type Settings struct {
// EpochDuration is how often the cache re-evaluates its policies on a
// wall clock. Either this or EpochRequests must be set; setting both
// applies both, and whichever comes first ends the epoch.
EpochDuration time.Duration
// EpochRequests ends an epoch every N Get calls instead of on a clock.
//
// Wall-clock epochs make a cache's behaviour depend on how fast the
// machine runs it: replaying one trace twice re-evaluates a different
// number of times, so the hit rate moves between runs and cannot be
// compared with anything. Counting requests removes the clock from the
// measurement entirely - the same trace produces the same epochs, the
// same switches and the same hit rate on any machine, which is what a
// benchmark or a regression test needs.
//
// Get is the unit because Get is what produces evidence: hits and misses
// are recorded there and nowhere else, so this counts exactly the
// requests the bandit is shown. A workload that only writes never ends an
// epoch, which is correct - there is nothing to compare policies on.
//
// The epoch runs on the goroutine that happens to make the Nth Get, so
// that one call pays for the switch and any migration it triggers. In
// production prefer EpochDuration, which keeps that work on the
// background goroutine. Zero (the default) disables request counting.
EpochRequests int64
// EvictPartialCapacityFilling allows policy switching even when the cache
// is not yet full.
EvictPartialCapacityFilling bool
// MigrationStrategy determines how data is moved when the active policy
// changes. Defaults to MigrationCold (zero value).
MigrationStrategy MigrationStrategy
// MinHitRateImprovement is the hit-rate advantage, as an absolute
// difference in [0,1], that the bandit's selection must hold over the
// active policy in the epoch just measured before the switch is applied.
// It damps oscillation between policies that perform almost identically.
// Zero (the default) applies every selection the bandit makes.
MinHitRateImprovement float64
// SwitchCooldownEpochs is the number of epochs that must elapse after a
// policy switch before another switch is allowed, counted from the last
// switch or from cache creation if none has happened yet. Zero (the
// default) allows a switch on every epoch.
SwitchCooldownEpochs int64
// MinEpochRequests is the number of requests (hits plus misses) both the
// active policy and the candidate must have observed in the measured
// epoch before a switch is allowed, so the cache does not react to a
// handful of samples. Zero (the default) imposes no minimum.
//
// The requests counted are the ones the bandit sees, which under
// ShadowSampleRate means sampled requests: at a rate of 0.05 a threshold
// of 100 is reached after roughly 2000 real requests.
MinEpochRequests int64
// ShadowSampleRate is the fraction of the keyspace, in (0,1], that shadow
// policies track. Shadows exist only to estimate a hit rate, and a hit
// rate can be estimated from a sample: at 0.05 a shadow skips 95% of the
// operations it would otherwise mirror, which is where the bulk of the
// adaptive layer's overhead goes.
//
// Shadows shrink with the rate so each remains a faithful miniature of a
// full-size cache, and every shadow samples the same keys so their hit
// rates stay comparable. The active policy still serves every key; only
// the measurement is sampled, and it is sampled for the active policy too
// so that all arms carry equally weighted evidence.
//
// Zero (the default) means 1: shadows mirror every key, which is the
// behaviour of earlier versions.
ShadowSampleRate float64
// ObserveOnly runs the cache as a measurement instrument: every policy is
// still measured each epoch and reported to the bandit, but the active
// policy never changes and no migration ever happens.
//
// This is the zero-risk way to adopt the library. The cache behaves
// exactly like the single policy you gave it first, while Advice() answers
// the question that is otherwise expensive to ask: would a different
// eviction policy serve this traffic better, and by how much. Once the
// answer is in, either switch to that policy directly or turn this off and
// let the bandit do it.
ObserveOnly bool
// MinShadowCapacity is the floor on a shadow's miniature capacity. A
// miniature of a handful of entries measures noise rather than a policy,
// so when the sample rate would shrink a shadow below this floor the
// effective rate is raised instead, up to the point where sampling
// disables itself entirely. Zero (the default) applies
// DefaultMinShadowCapacity.
MinShadowCapacity int
}
Settings configures the behaviour of AdaptiveCache.
type ShadowStats ¶
type ShadowStats struct {
Policy PolicyType
Hits int64
Misses int64
}
ShadowStats holds one policy's hit/miss counts since its last report — normally one epoch, or several when reporting was skipped because the cache was not yet full (EvictPartialCapacityFilling=false). Every policy reports through this channel on each reporting epoch, the active policy included, so the bandit's posterior for the active arm does not go stale while reports flow.