Skip to content

perf: O(1) expected random pick for RRCache eviction and random_key - #115

Merged
awolverp merged 1 commit into
awolverp:mainfrom
greenclaw:perf/rr-evict
Oct 28, 2026
Merged

awolverp merged 1 commit into
awolverp:mainfrom
greenclaw:perf/rr-evict

Conversation

@greenclaw

Copy link
Copy Markdown
Contributor

Problem

RRCache picks the entry to evict (and the key for random_key()) with table.iter().nth(random). RawIter doesn't override nth, so this walks the table: every eviction is O(n). With a full cache, the cost of an insert grows linearly with maxsize, and RRCache becomes slower than cachetools' pure-Python RRCache from ~10k entries.

Fix

Pick a random bucket index and keep the first occupied one (RawTable::checked_bucket), up to 32 tries, then fall back to the previous iter().nth(random).

  • Uniform: each accepted probe is uniform over the occupied buckets, and so is the fallback.
  • Expected O(1) for a full cache: hashbrown only grows by doubling, so a table that grew through insertions stays roughly a quarter occupied or more, even after eviction churn (a few probes on average; the fallback is reached about once in 10⁴ evictions).
  • Sparse tables (pre-sized via capacity, thinned by removals, or a few heavy entries under getsizeof) still work: they fall back to the old O(n) pick.

Results

Get-or-set loop on a pre-filled cache, key space 4x maxsize (miss-heavy). ns/op, lower is better, best of 3 interleaved runs. main and this branch built the same way (maturin develop --release), CPython 3.13, macOS arm64.

maxsize insert + evict: main this PR random_key(): main this PR
100 109 57 1.9x 40 22 1.8x
1 000 338 52 6.5x 241 29 8.3x
10 000 2 218 51 43x 2 074 26 80x
100 000 22 954 68 340x 19 412 39 500x

Tests

New tests in TestRRCache: random_key() is uniform in a full table and in a sparse one (the fallback path), popitem() drains a sparse table, and eviction keeps the cache full. The statistical checks use a ±30% tolerance, about 10 standard deviations, so they don't flake.

Full suite in the CI configuration (debug, --features use-small-offset, HYPOTHESIS_PROFILE=slow, -n auto): 1152 passed on 3.13, 1151 passed on free-threaded 3.14t. The Python Test workflow passes on the fork across the whole matrix (3.10–3.15, 3.14t/3.15t, PyPy, ubuntu/macos/windows).

Independent of #114 (the HashTable question): this keeps the vendored RawTable.

RRCache.evict() and random_key() chose an entry with
table.iter().nth(random), which walks the table: O(n) per eviction.
With a full cache of 100k entries an insert cost ~23 µs.

Pick a random bucket index and keep the first occupied one instead.
Conditioned on hitting an occupied bucket, every entry is equally likely,
so the distribution stays uniform. hashbrown only grows by doubling, so
a full cache stays roughly a quarter occupied or more even after
eviction churn, which means a few probes on average. A sparser table
(pre-sized via `capacity`, thinned by removals, or holding few heavy
entries under `getsizeof`) falls back to the old uniform O(n) pick after
32 misses.

Tests cover uniformity in a full and in a sparse table, draining a sparse
table with popitem(), and keeping the cache full under eviction.
@awolverp
awolverp merged commit cff9073 into awolverp:main Oct 28, 2026
12 checks passed
@greenclaw
greenclaw deleted the perf/rr-evict branch October 29, 2026 11:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants