Skip to content

Add GetMany to sieve, s3fifo, tinylfu and stats - #8

Merged
floatdrop merged 1 commit into
mainfrom
getmany
Nov 12, 2026
Merged

floatdrop merged 1 commit into
mainfrom
getmany

Conversation

@floatdrop

Copy link
Copy Markdown
Owner

What

GetMany(keys, values, found) on the three lock-free caches and on stats.Cache. It does what Get does for each key, in order.

  • For up to 32 keys at a time, while at least 4 are left, it hashes them, loads the index slot each lookup starts at (cslab.Slab.Touch) and the node it points to (TouchNode), then runs the package's own lookup. The CPU fetches the memory of the whole batch in parallel.
  • The last 1–3 keys of a call go through Get.
  • It panics if values or found is shorter than keys, before it looks anything up.
  • stats.Wrap uses the wrapped cache's GetMany if it has one, else loops Get, and counts a hit or a miss per key.
  • Not on evict.Cache: lru and twoq have no GetMany.

Results (Apple M3 Max)

Change in time per key against a Get loop: ranges over the three caches of medians of three runs of BenchmarkGetMany, keys picked at random.

1 CPU 8 CPUs
1M int entries, 32 keys per call −48 to −62% −47 to −53%
1M int entries, 8 keys per call −36 to −49% −27 to −34%
1M entries, 23-byte string keys, 32 per call −66 to −68% −56 to −58%
Zipf read-through with room for 1M, 32 per call −49 to −55% −48 to −51%
16K int entries, 32 keys per call −2 to +3% +1 to +9%
1M int entries, 1 key per call +40 to +86% +32 to +80%
  • One key is slower through GetMany even though it only calls Get, and I did not find why: not the batch arrays, not the frame size, not inlining in the benchmark. The docs say to use Get for one key.
  • The absolute times moved more between runs than these ratios, so the README gives ratios.
  • Only darwin/arm64 was measured.

Why a batch method

A prototype was reviewed twice by separate agents, who reproduced the gain with interleaved one-process-per-variant runs and looked for ways to get it without a new method:

  • Removing the pin CAS and the unpin store from Get made int lookups barely faster.
  • Touching one key ahead of each Get gained nothing.
  • A caller touching its whole batch and then calling Get got 85–90% of the gain but hashed every key twice, 36% slower on a 16K cache.

What pays is many independent loads issued back to back, which needs the keys in hand.

Checked

  • Each package has tests that GetMany matches Peek key by key, for lengths around 32 and every remainder from 0 to 4, and with values being keys itself.
  • Read counts after Get and after GetMany are compared per key, also with every reader slot taken so that lookups go through the lock. s3fifo also compares its queues after evictions, sieve the keys left, and tinylfu the hits its window hears of.
  • Other tests: concurrent writers with keys that must always be found, help-out reclaiming, short slices writing and counting nothing before the panic, and no allocations.
  • Mutations caught: not counting reads; the locked fallback not counting, dropping the value, or never taken; no help; no length check; the fallback using Peek or skipping keys; tinylfu not tallying hits on either path.
  • Inline check: GetMany calls neither Touch nor TouchNode, used directly or through a generic type of another package; marking either //go:noinline makes it fail. stats.Cache.GetMany is covered the same way.
  • golangci-lint 0 issues; go test -race ./... passes; GOOS=linux GOARCH=386 go vet ./... clean.

🤖 Generated with Claude Code

GetMany(keys, values, found) does what Get does for each key. For up to
32 keys at a time, while at least four are left, it hashes them, loads
the index slot that each lookup starts at and the node it points to,
and only then looks them up, so that the CPU fetches the memory of all
of them in parallel. The last few keys of a call go through Get.

With a million entries and keys picked at random, a key took 47 to 62%
less time than with a Get loop with 32 keys per call, on one CPU and on
eight, and 27 to 49% less with 8 keys per call. With 16K entries, which
fit in the CPU's caches, it is within 10% of Get. A single key is
faster with Get: through GetMany it took 32 to 86% longer, for a reason
not found. Medians of three runs of BenchmarkGetMany on an Apple M3 Max.

The gain needs the keys in hand at once: touching one key ahead of each
Get does nothing, and the CAS that pins a node is not what slows Get.

stats.Wrap passes GetMany on to the cache it wraps if it has one, and
calls Get for each key otherwise; it counts a hit or a miss per key.

Each package checks that GetMany counts reads and hits as Get does,
also when it has to take the lock, that it helps reclaim, that short
slices make it panic before it does anything, and that it allocates
nothing. The inline check covers it, directly and through a generic
wrapper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@floatdrop
floatdrop merged commit 153c17f into main Nov 12, 2026
7 checks passed
@floatdrop
floatdrop deleted the getmany branch November 12, 2026 16:51
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.

1 participant