Skip to content

lru: accounting per-key overhead - #23552

Open
AskAlexSharov wants to merge 10 commits into
mainfrom
alex/cache_slot_accounting_37
Open

lru: accounting per-key overhead#23552
AskAlexSharov wants to merge 10 commits into
mainfrom
alex/cache_slot_accounting_37

Conversation

@AskAlexSharov

@AskAlexSharov AskAlexSharov commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

lru accounting logic has bug: didn't account overhead metada per-key. but in some cases our keys/vals are small and it's noticable

cache budget before after
accounts 150 MB 1,638,400 418,816
storage 1 GB 12,201,611 3,354,624
jumpdest 64 MB 262,144 104,448

At 256 shards. The ceiling now moves with the shard count, because each shard's
table is fitted to the 5/4 boundary rather than charged a flat per-slot constant —
that flat charge was wrong by up to ±292 MB on the storage cache purely as a
function of GOMAXPROCS.

Grow steps are charged from the real table on both sides of the step, not from the
fitted ceiling: at a power-of-two generation freelru allocates 2x the capacity, so
an intermediate generation costs about double what it used to be charged. That is
what it was really allocating, and it means a cache can now be refused a step it
used to get.

@AskAlexSharov
AskAlexSharov force-pushed the alex/cache_slot_accounting_37 branch 4 times, most recently from 8c8b970 to 1aa2db4 Compare August 25, 2026 08:02
The budget derived its entry-count ceiling from the payload estimate alone,
but freelru allocates nextPowerOfTwo(capacity*5/4) elements plus a bucket
index per slot -- 232 B for the domain caches against 88 charged. A 1GB
storage budget bought ~12.2M slots and ~2.9GB of RAM. Charge the table too,
and clamp the ceiling to the largest capacity the table already covers, so a
budget no longer pays for elements it cannot use.
@AskAlexSharov
AskAlexSharov force-pushed the alex/cache_slot_accounting_37 branch from 1aa2db4 to 142e798 Compare August 25, 2026 08:10
@AskAlexSharov
AskAlexSharov changed the base branch from alex/cache_grow_latch_37 to main August 25, 2026 08:10
@AskAlexSharov AskAlexSharov changed the title execution/cache: charge the slot array to the cache byte budget lru: accounting per-key overhead Aug 25, 2026
@AskAlexSharov
AskAlexSharov marked this pull request as ready for review August 25, 2026 08:13
AskAlexSharov and others added 2 commits August 26, 2026 09:41
# Conflicts:
#	execution/cache/generic_cache.go
#	execution/cache/generic_cache_concurrency_test.go

@domiwei domiwei left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the accounting change — the direction is right, and I verified the table in the description: all three before/after numbers recompute exactly from the new formula. The new test really pins the bug (pre-fix it reserves 262144×88 ≈ 23 MB against ~61 MB actually allocated, so it fails red). Envelope symmetry also checks out: birth / fundGrow / refundGrow / Clear / Close / Purge all go through the single perSlot field, so the reservation always equals curCap×perSlot.

Three things worth addressing (details in the inline comments):

  1. The envelope still under-reserves the slot array in reachable default configs — capFitsTable is a near-identity in practice, and freelru's per-shard ceil split can double every shard table (storage default at 256 shards: real table 928 MB vs 742 MB charged).
  2. The new maxCacheBytes clamp silently caps user-configured budgets (STATE_CACHE_*, StateCacheBudget) above 1 GiB — an existing 4 GB override now behaves exactly like 1 GB, with no log.
  3. The code-size layer's entry ceiling silently drops from 1,000,000 to 216,216, because its budget is synthesized as entries*codeSizeEntryBytes expecting the old divide.

Smaller nits:

  • freelruSlotBytes' 112 is a hardcoded mirror of freelru's element size — exact for entry[[]byte] and codeEntry, conservative for codeSizeEntry (96 B). An unsafe.Sizeof static assert would keep a future larger value type from under-charging silently.
  • "Stacked on #23546" in the description is stale — that PR was closed unmerged, and this diff is clean against main.

Comment thread execution/cache/generic_cache.go Outdated
Comment thread execution/cache/generic_cache.go Outdated
Comment thread execution/cache/grow_lru.go Outdated
Comment thread execution/cache/generic_cache_concurrency_test.go Outdated
capFitsTable was an identity function: NextPowerOfTwo(c+c/4)/5*4 >= c for every c,
so only integer-truncation crumbs were trimmed and nothing pinned the table ratio.
freelru is then asked for NextPowerOfTwo(perShard*5/4) elements per shard, and
the per-shard split is where the whole-cache computation broke: the real ratio
landed anywhere in [5/4, 5/2) while the charge was a flat 2x. Storage at its 1 GiB
default came out 195MB short at 256 shards and 292MB over at 128 or 512 -- the
error swung with GOMAXPROCS, in both directions.

fitTableSlots now rounds a shard's capacity down to 4/5 of a power of two, the
only capacity freelru does not round up, and the slot count and shard count are
derived together so the charge matches what is allocated. growLRU gets its own
computation: freelru.NewSharded rounds the whole capacity rather than each shard.

The 1 GiB byte clamp is gone -- it silently pinned STATE_CACHE_STORAGE=4GB to the
same slot count as 1GB while capacityB kept the unclamped value that PrintStats
and ModeNoOp report against. The old 1<<24 slot ceiling is back in its place.

The size layer is built from its entry count again rather than a synthesized byte
budget, which had dropped its ceiling from 1,000,000 to 216,216.

The envelope test now runs the production maxCap across 128/256/512 shards and
compares only the overhead share of the reserve: the payload estimate pays for
values the table does not hold, so including it let an undercharged table pass.
1 << 24 does not read as a slot count.
@AskAlexSharov
AskAlexSharov requested a review from domiwei August 27, 2026 07:54
@yperbasis

Copy link
Copy Markdown
Member

@AskAlexSharov It says "Stacked on #23546", but 23546 is closed?

@AskAlexSharov

Copy link
Copy Markdown
Collaborator Author

@AskAlexSharov It says "Stacked on #23546", but 23546 is closed?

stale description

@yperbasis
yperbasis requested a balanced review from Copilot August 28, 2026 10:34
@AskAlexSharov

Copy link
Copy Markdown
Collaborator Author

The two smaller nits are closed; head is 1a2a53e981. The three numbered issues were fixed earlier in the inline threads.

  • freelruSlotBytes' hardcoded 112freelru.element is unexported, so the assert goes on the value types that determine its size:

    const freelruValueBytes = 72
    const (
        _ = uint(freelruValueBytes - unsafe.Sizeof(entry[[]byte]{}))
        _ = uint(freelruValueBytes - unsafe.Sizeof(codeEntry{}))
        _ = uint(freelruValueBytes - unsafe.Sizeof(codeSizeEntry{}))
    )

    Checked it bites: dropping the constant to 71 fails the build on entry[[]byte] and codeEntry (both 72 B) and passes codeSizeEntry (56 B, the conservative case you named). A larger value type can no longer under-charge silently.

  • "Stacked on lru: fastpath before taking resizeMu in maybeGrow #23546" — gone from the description.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Updates LRU memory accounting to include freelru metadata overhead.

Changes:

  • Adds per-slot overhead calculations and capacity fitting.
  • Preserves entry-count semantics for the code-size cache.
  • Adds allocation-envelope coverage testing.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
execution/cache/grow_lru.go Adds overhead-aware grow-LRU sizing.
execution/cache/generic_cache.go Accounts for freelru slot metadata.
execution/cache/generic_cache_concurrency_test.go Tests envelope allocation coverage.
execution/cache/code_cache.go Uses an entry-count constructor for code sizes.
Suppressed comments (2)

execution/cache/grow_lru.go:80

  • The byte-derived slot count is narrowed to uint32 before it is capped. Once the quotient exceeds math.MaxUint32, it wraps and may create a much smaller cache rather than selecting maxCacheSlots. Apply the cap while the value is still uint64.
	perSlot := int64(avgBytes) + freelruSlotBytes
	maxCap := max(fitTableSlots(min(uint32(uint64(maxBytes)/uint64(perSlot)), maxCacheSlots)), 1)
	return newGrowLRUWith(maxCap, perSlot, onEvict)

execution/cache/generic_cache.go:94

  • The quotient is converted to uint32 before applying maxCacheSlots. For a valid large byte budget where the quotient exceeds math.MaxUint32, this wraps and can produce a tiny cache instead of saturating at the configured ceiling. Clamp in uint64 first, then convert.
	perSlot := uint64(payloadBytes) + freelruSlotBytes
	approx := min(uint32(uint64(capacityBytes)/perSlot), maxCacheSlots)
	shards = initialShardCount(approx, shardCeil())

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread execution/cache/generic_cache.go
Comment thread execution/cache/grow_lru.go Outdated

@yperbasis yperbasis left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes for two accounting regressions. Intermediate grow generations still under-reserve the freelru table, so the shared memory envelope can be exceeded. Also, the byte-usage metric no longer shows when the cache reaches its new overhead-aware entry ceiling. I reproduced the first issue on this head; the existing execution/cache tests pass because the new envelope test measures only the fitted final generation.

Comment thread execution/cache/generic_cache.go
Comment thread execution/cache/generic_cache.go Outdated
…d ceiling

freelru rounds capacity+25% up to a power of two and sizes both arrays at the
result, so a power-of-two generation gets a 2x table while the envelope charged
the ceiling's 5/4 — 153MB reserved against 243MB allocated at 1M slots. Charge
each step from tableSlots of the two capacities, and report payload usage
against the payload the ceiling buys rather than the whole budget.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Suppressed comments (1)

execution/cache/generic_cache.go:251

  • avgBytes still includes the 24-byte per-entry overhead (putStriped and the account/storage averages all add it), while freelruElemBytes now charges for the complete freelru element, including the inline entry[T]. Treating the unchanged estimate as external payload therefore double-counts that overhead, reducing the account/storage ceilings and consuming the shared envelope early. Split the estimates into externally allocated key/value bytes versus table-resident bytes, or adjust the callers before using them as payloadBytes.
		payloadBytes: int64(avgBytes),

Comment thread execution/cache/code_cache.go Outdated
Comment thread execution/cache/grow_lru.go Outdated
Comment thread execution/cache/generic_cache.go Outdated
…m for, and clamp the slot quotient in uint64
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants