Skip to content

Substrate: give PoolAllocator a FIFO free list - #113

Open
PowerOfNames wants to merge 1 commit into
developfrom
108_PoolAllocatorFiFo
Open

Substrate: give PoolAllocator a FIFO free list#113
PowerOfNames wants to merge 1 commit into
developfrom
108_PoolAllocatorFiFo

Conversation

@PowerOfNames

Copy link
Copy Markdown
Owner

Closes #108.

Why

Allocate popped m_FreeHandles.back() and Free pushed back — LIFO, so a churning caller cycled a handful of indices and burned their generation counters to exhaustion while the rest of the pool sat untouched. Resize churn proved it: ~6 image slots absorbed everything and the first retired after about 63 resizes, roughly a second of dragging a window border.

FIFO spreads the burn across every slot and keeps a freed handle detectably stale for far longer, which makes use-after-free easier to catch.

Shape

A ring buffer over the existing fixed-capacity m_FreeHandles, plus head, tail and an explicit m_FreeCount. The count is what disambiguates full from empty — the ring is completely full at construction, so head == tail is the normal state rather than an edge case. MAX_BLOCK_COUNT is not always a power of two (85 in the tests, 1024 in Aurora), so the wrap is a compare, never a mask.

The perf/instrumentation harness originally scoped alongside this was dropped: a microbenchmark on a fixed-size pool would mostly measure the branch predictor. LIFO's tail-only access only pays when allocate and free happen in close succession; across any real gap the cache holds other work anyway, and the ring stays contiguous regardless.

Bug fixed along the way

BaseHandle::IsValid() treated "all index bits set" as invalid. When the block count is an exact power of two the highest valid index equals the index mask, so that slot was allocatable but never freeable — Free took the retirement branch immediately, the index never returned to the free list, and m_CurrentAllocationCount never decremented. Both Aurora pools are that shape at 1024 blocks, and the FIFO change promotes this from latent to reachable: under LIFO the top index only came out at a completely full pool, but the ring head sweeps every index regardless of how many slots are live.

Validity now keys off the generation alone, which already subsumes the all-ones sentinel. A handle type must leave at least one index bit, enforced by static_assert rather than by every instance reading invalid.

Also

  • Slot retirement is observable: GetMaxedGenerationCount() on AllocatorBase. Read alongside GetCurrentAllocationCount(), which still counts retired slots and so overstates live allocations once any retire.
  • GetFreeHandleIndices() removed — the backing vector is full-length forever now, so its .size() no longer means anything.

Tests

783 assertions in 29 cases, green. Sandbox verified in Debug and Release.

  • Rewrote the two PoolAllocator sections that asserted LIFO reuse.
  • Moved generation exhaustion onto a small uint16_t pool: 16383 generations instead of 33.5M, so it runs in milliseconds.
  • Added rotation, burn-spreading, ring-wrap, retirement, and a power-of-two top-slot regression for the IsValid bug above.
  • Added the missing CreateRefFromThis coverage, including the consumer shape that caught the original missing AddRef — a scope-local object copying the returned Ref into a member.
  • Removed two TestAllocationHandle sections that instantiated DefineHandle<16, 0, uint16_t>. They pinned the old runtime behaviour for an all-generation mask, which is now a compile error; the contract moved from runtime to compile time.

Follow-ups, not in this PR

  • Free still has no already-retired guard, so Free(INVALID_HANDLE) after the top slot retires inflates GetMaxedGenerationCount(). One-line fix, same shape as the guard now in IsHandleValid.
  • PoolAllocator's MustBePowerOFTwo constrains nothing and cannot be repaired as written — asserting on TSize rejects Aurora's pools, asserting on MAX_BLOCK_COUNT rejects the test pool. Needs a decision about what it was protecting.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HGxv2j7K5MFmFMX1iKrgcq

Allocate popped m_FreeHandles.back() and Free pushed back, so a churning
caller cycled a handful of indices and burned their generation counters to
exhaustion while the rest of the pool sat untouched. Resize churn retired the
first image slot after roughly 63 resizes. The free list is now a ring buffer
over the same fixed-capacity index array -- head, tail and an explicit free
count -- so reuse rotates through every slot and a freed handle stays
detectably stale for far longer. MAX_BLOCK_COUNT is not always a power of two,
so the wrap is a compare rather than a mask.

BaseHandle: IsValid() no longer treats "all index bits set" as invalid. When
the block count is an exact power of two the highest valid index equals the
index mask, which made that slot allocatable but never freeable -- Free took
the retirement branch immediately, so the index never returned to the list and
the allocation count never decremented. Both Aurora pools are that shape at
1024 blocks. Validity now keys off the generation alone, which already
subsumes the all-ones sentinel. A handle type must leave at least one index
bit, enforced by static_assert instead of by every instance reading invalid.

Slot retirement is observable: GetMaxedGenerationCount() on AllocatorBase,
alongside GetCurrentAllocationCount(), which still counts retired slots.

Tests: rewrote the two PoolAllocator cases that asserted LIFO reuse, moved
generation exhaustion onto a small uint16_t pool so it runs in milliseconds
rather than 33.5M iterations, and added coverage for rotation, burn spreading,
ring wrap, retirement and the power-of-two top slot. Added the missing
CreateRefFromThis cases, including the consumer shape that caught the original
missing AddRef. Removed two TestAllocationHandle sections that instantiated
DefineHandle<16, 0, uint16_t>, which the new static_assert rejects.

783 assertions in 29 cases, green. Sandbox verified in Debug and Release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HGxv2j7K5MFmFMX1iKrgcq
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.

PoolAllocator change to FiFo

1 participant