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
- 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)¶
By default, intelligent purging is enabled with conservative settings:
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.
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:
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.
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> 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.
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