Intelligent Pooling System¶
TL;DR: Why Use This¶
- Automatic memory management with intelligent purging that adapts to usage patterns.
- Avoid GC spikes by spreading purges across frames and responding to memory pressure.
- Type-specific policies for different object lifetimes (short-lived lists vs long-lived audio sources).
- Zero-configuration defaults that "just work" with opt-in customization.
Contents¶
- Overview
- Quick Start
- PoolOptions Configuration
- Global Settings (PoolPurgeSettings)
- Eviction Policies
- Memory Pressure Detection
- Size-Aware Policies
- Access Frequency Tracking
- Application Lifecycle Hooks
- Global Pool Registry
- Pooling IDisposable Objects
- Renting an Array
- Borrowing a Buffer for One Operation
- Best Practices
Overview¶
The intelligent pooling system provides automatic memory management for WallstopGenericPool<T> instances. Instead of pools growing unbounded or requiring manual purge calls, the system:
- Tracks usage patterns - Monitors high-water marks and access frequency
- Purges intelligently - Only removes items unlikely to be needed soon
- Spreads work - Limits purges per operation to avoid GC spikes
- Responds to pressure - Aggressive cleanup when memory is low
- Respects object size - Large objects get stricter policies
flowchart TB
subgraph "Intelligent Purging Flow"
Access[Pool Access] --> Track[Track Usage]
Track --> Check{Purge Trigger?}
Check -->|Yes| Eligible{Items Eligible?}
Eligible -->|Idle Timeout Exceeded| Purge[Purge Items]
Eligible -->|No| Skip[Skip Purge]
Purge --> Limit{Max Purges/Op?}
Limit -->|Reached| Pending[Mark Pending]
Limit -->|Not Reached| Continue[Continue]
end Quick Start¶
Basic Usage (Zero Configuration)¶
Intelligent purging is enabled with conservative defaults in both the Editor and players. Players need no Editor settings asset. A hidden runtime maintenance component checks registered pools every 30 seconds of real time, including when no code rents from an idle pool. Each pool keeps its own purge interval, idle timeout, retention floor, and gradual purge limit. Maintenance runs on the main thread while Unity produces frames, even when the time scale is zero.
Lease returns also check the global 50,000-item budget at most once every 30 seconds. Budget cleanup respects each pool's absolute retention floor, so configured floors can exceed the budget. Callbacks run outside the registry and storage locks.
Call PoolPurgeSettings.DisableGlobally() before creating pools to opt out of intelligent purging, or set UseIntelligentPurging = false for a pool. Set IdleTimeoutSeconds = 0 to explicitly disable idle cleanup, or omit PurgeTrigger.Periodic to opt out of runtime periodic maintenance. Set GlobalPoolRegistry.BudgetEnforcementEnabled = false to disable automatic budget checks. Explicit capacity limits take priority over warm retention and spike hysteresis. Purges with an explicit reason retain the absolute minimum; parameterless Purge() keeps the active warm target.
Basic use needs no configuration:
A failed producer or onGet callback does not leave an active rental. If onGet fails after an item is created or removed from storage, the pool retires that item through onDisposal and propagates the original failure. Failed prewarming retires every item created before the failure, including the item whose initialization failed. Acquisition callbacks run outside the storage lock, so they can query pool state from another thread. Rental counters record attempts; a failed attempt is removed from the active rental count.
Disposing PooledResource<T> runs the configured release callback before parking the item. If that callback disposes the pool, the returning item is sent to the disposal callback exactly once and is never added back to the disposed pool. The same guarantee holds when a lease return races WallstopGenericPool<T>.Dispose() in the thread-safe build.
A purge removes its complete selected batch before running OnPurge and onDisposal, including in SINGLE_THREADED builds. Those callbacks may rent, dispose or purge the same pool: the original batch still receives each cleanup notification once, and a callback cannot rent an entry selected for retirement. Nested purges act on the remaining pool; budget limits and minimum retention apply when each batch is selected.
Purge and disposal snapshots use independent reusable buffers. Each thread and snapshot element type retains at most four empty buffers, each with capacity at most 4,096 entries. Nested callbacks hold distinct buffers until their batches finish; returning a buffer clears its item references. Cold growth, deeper nesting and larger batches can still allocate, and oversized buffers are discarded after cleanup.
Internal snapshot leases return each buffer once, even if the lease is copied or disposed again after that buffer has a new owner. Cleanup runs when the owning scope exits, including early returns and exceptions. Automatic purges acquire their snapshots only when they select an item.
A directly constructed PooledResource<T> with a null return callback is an inert wrapper. It keeps the supplied resource accessible and reserves no disposal slot, even if it is never disposed. Disposing or copying it has no effect on the resource. Wrappers with a return callback retain their copy-safe, at-most-once release guarantee.
Disable Globally (One-Liner Opt-Out)¶
Per-Type Configuration¶
PoolOptions Configuration¶
PoolOptions<T> provides per-pool configuration:
PurgeTrigger Flags¶
| Trigger | Description |
|---|---|
OnRent | Check when item is rented (lazy cleanup) |
OnReturn | Check when item is returned |
Periodic | Timer-based checks at PurgeIntervalSeconds |
Explicit | Only purge when Purge() is called manually |
PurgeReason Values¶
| Reason | Description |
|---|---|
IdleTimeout | Item was idle longer than IdleTimeoutSeconds |
CapacityExceeded | Pool exceeded MaxPoolSize |
MemoryPressure | System memory pressure detected |
AppBackgrounded | Application went to background |
SceneUnloaded | Scene was unloaded |
Explicit | Manual Purge() call |
BudgetExceeded | Global pool budget exceeded |
Global Settings (PoolPurgeSettings)¶
Configure system-wide defaults:
What Usage Tracking Costs¶
Every rental records the pool's concurrent-rental count so purging can size the pool from how it is actually used. Those samples go into a fixed ring of 66 time buckets, 64 of which cover RollingWindowSeconds and two of which are the margin that makes expiry late rather than early, allocated once when the pool is constructed: recording is O(1), never allocates, and costs the same 2 KB whether a pool is rented twice or ten million times. Peak and average are exact over the samples still inside the window; a sample leaves the window up to two bucket durations late, never early, so a shorter RollingWindowSeconds also buys finer expiry.
Retention Model¶
The system uses a two-tier retention model:
- MinRetainCount: Absolute floor. Pool never purges below this, even when completely idle.
- WarmRetainCount: Floor for "active" pools (accessed within IdleTimeoutSeconds). Prevents cold-start allocations.
| Text Only | |
|---|---|
Example:
MinRetainCount = 0,WarmRetainCount = 2- Active pool: keeps at least 2 items warm
- Idle pool (no access for IdleTimeoutSeconds): can purge to 0
Eviction Policies¶
Comfortable Size Calculation¶
The "comfortable size" determines when purging is needed:
| Text Only | |
|---|---|
Items that have been idle longer than IdleTimeoutSeconds are purged regardless of comfortable size. The comfortable size primarily influences the target retention during non-idle purges and memory pressure events.
Hysteresis Protection¶
After a usage spike, purging is suppressed for HysteresisSeconds to prevent purge-allocate cycles:
sequenceDiagram
participant App as Application
participant Pool as Pool
Note over App,Pool: Normal usage period
App->>Pool: Get items (low volume)
Pool->>Pool: Track high-water mark
Note over App,Pool: Usage spike detected
App->>Pool: Get many items rapidly
Pool->>Pool: Spike! Start hysteresis
Note over App,Pool: Hysteresis period (2 min default)
Pool->>Pool: Purging suppressed
Note over App,Pool: After hysteresis
Pool->>Pool: Resume normal purging Gradual Purging¶
Large purge operations are spread across multiple calls:
| C# | |
|---|---|
Memory Pressure Detection¶
The system monitors memory pressure and adjusts purging aggressiveness:
Pressure Detection Sources¶
| Metric | Threshold |
|---|---|
| Absolute Memory | Managed heap exceeds threshold |
| GC Collection Rate | Frequent GC collections detected |
| Memory Growth Rate | Rapid memory increase |
| Application.lowMemory | Unity's low memory callback |
Size-Aware Policies¶
Large objects (allocated on the Large Object Heap) get stricter policies:
PoolSizeEstimator¶
Estimate object sizes for policy decisions. Value types use marshalled sizes when available and field-based estimates otherwise; managed layout and backend overhead can differ. These are policy estimates, not allocation measurements:
Access Frequency Tracking¶
Pools track access patterns for intelligent decisions:
Application Lifecycle Hooks¶
The system responds to application lifecycle events:
| C# | |
|---|---|
Mobile Considerations¶
On mobile platforms:
- App backgrounded: Aggressive purge to reduce memory footprint
- Low memory: Emergency purge, bypasses gradual limits
- Scene unload: Clean up scene-specific pools
Global Pool Registry¶
Track and manage all pools system-wide:
LRU Cross-Pool Eviction¶
When the global budget is exceeded, items are evicted across all pools using LRU ordering based on pool access times.
Comparer-Keyed Pools¶
SetBuffers<T>.GetHashSetPool, GetSortedSetPool, DictionaryBuffer<TKey, TValue>.GetDictionaryPool and GetSortedDictionaryPool cache one pool per comparer instance. Each cached entry is a strong reference to your comparer, and a Unity comparer is often a MonoBehaviour, a ScriptableObject, or a closure capturing one -- so the cache is bounded rather than unbounded, and the least recently used comparer is evicted once the bound is reached. Losing a cached pool costs one pool construction the next time that comparer is used.
| C# | |
|---|---|
Raise it only if your game genuinely uses more than the default number of distinct comparers at once. Changing it resizes every live closed-generic comparer cache immediately; lowering the value evicts least-recently-used pools, while 0 or less removes the bound. All four caches use the shared Cache<TKey, TValue> implementation. DestroyHashSetPool, DestroySortedSetPool, DestroyDictionaryPool and DestroySortedDictionaryPool remain available to drop and dispose one pool explicitly.
Cache eviction intentionally stops tracking a comparer-keyed pool without disposing it. Callers receive and may retain that pool directly, so the cache cannot know when disposal is safe. Use the matching Destroy*Pool method when the caller owns the pool lifetime and can prove no borrower still uses it.
PoolTypeResolver uses the same shared cache for simplified type-name parsing. Set PoolTypeResolver.MaxCachedTypeNames to tune its live default-512 bound; lowering it evicts least-recently-used spellings immediately, and 0 or less removes the bound. Stored constructed generic names also resolve after a component assembly moves if each component still has one matching full name in loaded assemblies. Missing or ambiguous components do not resolve. Direct names, built-in aliases, and simplified names such as List<int> keep their existing syntax.
Pooling IDisposable Objects¶
Disposing is decided by the drop path, never by the type. A lease's Dispose() returns the instance to its pool so it can be handed out again -- it does not call Dispose() on the instance. The pool's onDisposal callback is the hook for that: it fires on every path an instance leaves the pool forever (pool Dispose, budget purge, memory-pressure purge, idle-timeout purge, or a return into an already-disposed pool). Pool an IDisposable without passing onDisposal and every one of those paths drops it unreleased:
| C# | |
|---|---|
Do not type-check is IDisposable inside a pool and dispose on clear: eviction and purge paths also drop objects that are merely cached for reuse -- the comparer-keyed caches above store pools of pools, and their WallstopGenericPool values are themselves IDisposable that callers hold directly. Ownership belongs to whoever dropped the instance from the pool, which is exactly what onDisposal expresses. Global budget enforcement snapshots its work before it invokes callbacks, so a callback can query or update the global registry without running under the registry lock. A callback may still re-enter from onRelease, so keep it short and non-throwing.
Best Practices¶
Configuration Hierarchy¶
Settings are resolved in priority order:
- Per-instance PoolOptions (highest priority)
- Programmatic type configuration (
PoolPurgeSettings.Configure<T>) - Generic type pattern (
PoolPurgeSettings.ConfigureGeneric) - Attribute-based (
[PoolPurgePolicy]on type) - Settings asset configuration
- Built-in type defaults
- Global defaults (lowest priority)
Type-Specific Recommendations¶
Renting an Array¶
SystemArrayPool<T>.Get rents from the process-wide ArrayPool<T>.Shared. Two consequences follow, and they pull in opposite directions.
A rented array is longer than you asked for. The shared pool rounds a request up to its bucket size: a minimum of sixteen, then powers of two. Use PooledArray<T>.length, never array.Length, and never hand the raw array to an API that reads all of it.
A rented array is not zeroed. Returning one never leaves a managed reference rooted: the package clears on return whenever T is, or contains, a reference, but nothing zeroes blittable data, and the shared pool hands out arrays that code outside this package returned. So every slot you read must be one you wrote:
Counters, visited flags and running sums all need clearArray: true. An algorithm that fills the array before reading it (a sort's scratch buffer, a copy destination) should not pay for it.
For an exactly-sized array whose size comes from a small, known set, use WallstopArrayPool<T>, which is always zeroed on return. Do not use it for a size derived from a collection count: it creates a permanent bucket per distinct size.
Borrowing a Buffer for One Operation¶
SystemArrayPool<T>.TryWithBuffer owns the rental while a synchronous callback receives a Span<T> of exactly the requested logical length. It issues no disposable lease. Use this when the buffer is needed only during that callback; keep Get for lifetimes that extend beyond one operation. Existing Get leases retain their copy-safe disposal checks.
Both overloads take length, explicit state, callback, out Exception error, and optional clearArray. The action overload accepts BufferAction<T, TState>. The result overload accepts BufferFunc<T, TState, TResult> and adds out TResult result before error. Static callbacks can use the state parameter without capturing local variables.
A zero length invokes the callback once with an empty span and performs no rent or return. Negative lengths and null callbacks report failure before renting. Reference-bearing elements, including structs with reference fields, always have their logical prefix cleared before the callback. This prevents stale elements from exposing the backing array through a reference stored inside it. These types also request clearing of the full physical array on return. For types without references, input clearing is optional; use clearArray: true when reading before writing. Otherwise, initialize each element before reading it.
Rent, callback, and return failures produce false with an observable error; a failed result resets to default. Callback exceptions remain available through error, and simultaneous callback and cleanup failures are both reported. After a successful rent, finally makes one return attempt. A return failure does not guarantee successful release and is not retried. Changes to caller state or other callback side effects are not rolled back on failure. Fatal runtime failures remain outside ordinary error recovery.
The borrowed span cannot be stored on the heap, captured, boxed, returned as TResult, or retained by an async callback. State, objects stored in elements, and owned copies such as buffer.ToArray() may outlive the callback; this API does not grant deep ownership of those objects. The storage contract assumes ordinary span operations and compliant underlying pool use. Low-level escape tools such as Unsafe, MemoryMarshal.CreateSpan, reflection, and native interop are outside that contract; disabling unsafe blocks alone does not exclude those tools.
Reference clearing and callback invocation still perform runtime work. Removing disposal-lease work does not establish a timing or allocation improvement for a particular workload.
Choose between a lease and a borrowed callback by the buffer's lifetime. Keep a lease when several helpers must share the same rental, or when work continues beyond the callback. Use a borrowed callback when all span access ends before the callback returns. For either form, profile the complete operation, including input clearing and callback creation, in your target player.
For examples of safe copies, returned results and interface boxing, see using pooled resources safely.
Performance Tips¶
- Use gradual purging - Default
MaxPurgesPerOperation = 10prevents GC spikes - Size buffers appropriately - 2x buffer is conservative, 1.5x for memory-constrained
- Monitor frequency stats - Use
FrequencyStatisticsto tune per-type settings - Enable size-aware policies - Large objects need stricter handling
- Use lifecycle hooks - Let the system handle mobile backgrounding
Debugging¶
Related Documentation¶
- Data Structures - Cache and other collections
- Helper Utilities - Coroutine wait pools (Buffers)
- Editor Tools Guide - Project settings