lru: grow 1 shard at a time - #23553
Merged
Merged
Conversation
AskAlexSharov
force-pushed
the
alex/lru_grow_one_shard_37
branch
from
August 25, 2026 11:41
58e1a67 to
918adfc
Compare
The jump-grow copied the whole cache into a new generation behind every put stripe, so one grow blocked every writer for as long as the copy took -- 195ms at 1M entries, and the ladder ends with a 4.19M-entry step. Own the shards instead of handing them to freelru.ShardedLRU: a full shard is rebuilt one step larger under its own lock, so a grow blocks one shard's writers for capacity/shards entries. Shard count is fixed for the life of the cache, which also removes the re-shard hazard the migration had to defend against.
AskAlexSharov
force-pushed
the
alex/lru_grow_one_shard_37
branch
from
August 25, 2026 12:34
918adfc to
ef97e8a
Compare
Contributor
There was a problem hiding this comment.
Pull request overview
Reworks GenericCache growth to resize one LRU shard at a time, reducing global write stalls.
Changes:
- Adds a shard-owned, independently growing LRU.
- Replaces whole-cache generation growth with per-shard growth.
- Retargets concurrency tests and adds a mixed-workload benchmark.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
execution/cache/sharded_lru.go |
Implements independently growing shards. |
execution/cache/generic_cache.go |
Integrates shard growth and budget accounting. |
execution/cache/generic_cache_concurrency_test.go |
Updates growth tests and adds a benchmark. |
Suppressed comments (3)
execution/cache/sharded_lru.go:135
- A capacity eviction invokes the callback and decrements
n, but the shard length is unchanged, sodeltais zero and the newly inserted entry is never counted. After reaching its ceiling, every eviction makesLen()drift downward (eventually negative). Compensate for the callback whenAddreports an eviction.
before := s.shards[i].Len()
evicted = s.shards[i].Add(h, v)
// freelru replaces a present key in place, returning false and firing no
// OnEvict, so the live count follows the shard's own length.
delta := s.shards[i].Len() - before
s.mus[i].Unlock()
if delta != 0 {
s.n.Add(int64(delta))
execution/cache/generic_cache.go:427
resizeMuis held before the following loop takes every put stripe, but a growingputStripedcall already holds one of those stripes and takesresizeMuinfundGrow. IfClearwinsresizeMu, the two goroutines wait on each other indefinitely. Fence the put stripes first, then takeresizeMuonly for the budget update.
next := c.newShards(c.startCap, c.maxCap, c.shardCount) // allocate before excluding writers
for i := range c.putStripes {
c.putStripes[i].Lock()
execution/cache/sharded_lru.go:153
- This doc comment names
growLocked, but the function ismigrateLocked.
// growLocked rebuilds shard i one step larger. Only that shard's readers and
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Contributor
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.
Suppressed comments (1)
execution/cache/generic_cache_concurrency_test.go:499
- The new implementation does not allocate slabs; the longest put performs a one-shard migration. Describing it as one slab allocation misstates what this benchmark measures.
// The longest a single put is held up while filling a cache from cold: on the
// jump-grow lineage that is a migration copy, with slab-allocated elements it
// is one slab allocation.
… shards Close settled reservedBytes under resizeMu, which no grow holds -- a grow runs under a put stripe. A grow racing Close either released its step a second time, under-counting the shared budget for the process lifetime, or funded one nothing gave back. Settle behind the same stripe fence Clear uses, and refuse to fund a step once closed. The entry count was incremented after the shard unlock but decremented inside it from the evict callback, so Len() could go negative: the ModeNoOp guard admits past the cap and any make() sized from it panics. Derive the count from the shard's own length under the lock and drop the callback's decrement. Also: overwrite through a shard-locked Replace instead of remove-then-add, which could span a rebuild; delete the unused Keys; stop allocating a shard array the constructor discards; hoist the duplicated GOMAXPROCS shard ceiling; assert the grow copy cannot evict; and give the grow tests a fill sized from the real shard geometry plus an assertion that a shard grew.
awskii
reviewed
Aug 26, 2026
awskii
approved these changes
Aug 26, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
lru-grow worst case on main:
copied=1048576 fenced=194.8ms. And a grow takes every put stripe — stop-the-world for concurrent RPC, parallel-exec and parallel-commitment alike.Solution: grow one shard at a time. A writer waits on that shard's mutex only; the other 255/256 of traffic is untouched. The generation swap, the all-stripe fence and
curCapgo away with it.Numbers vs main (n0, AMD EPYC 4344P, idle, benchstat)
Steady-state throughput — 1024 goroutines, hot/cold key mix, read-through fill, ~79% hit rate:
Worst single put while filling a cache from cold to 1M entries:
So the 92 ms all-writers stall becomes a 1.2 ms one-shard stall, cold fill is 3.7x cheaper, and steady-state throughput is unchanged. The extra bytes are the per-shard tables: shard count follows the ceiling, so more shards are allocated up front.
Falls out of it
TestGenericCache_GrowMigrationLosslesswas written to catch is structurally impossible; that test is retargeted at one shard's migration.Len()walk.nextPowerOfTwo(capacity*5/4), as freelru's sharded constructor does. Without it the bucket index falls back tofastModulo, which keys off the high hash bits — the same bits that pick the shard, so they are constant inside one, and a shard collapses onto a few chains.Reserve, refusable), and a step that loses the race hands its reservation back.