// MIT License - Copyright (c) 2026 wallstop // Full license text: https://github.com/wallstop/unity-helpers/blob/main/LICENSE namespace WallstopStudios.UnityHelpers.Utils { using System; using System.Collections.Generic; using System.Reflection; using System.Text; using System.Threading; using UnityEngine; using UnityEngine.SceneManagement; /// /// Global and type-specific configuration registry for intelligent pool purging. /// /// /// /// This class provides a hierarchical configuration system for pool purging behavior: /// /// Specific type configuration (e.g., List<int>) /// Generic type pattern configuration (e.g., List<> for any List<T>) /// Global defaults /// /// /// /// By default, intelligent purging is disabled. Enable it by setting /// to true or by configuring pool settings in the Unity Editor /// (Edit > Project Settings > Unity Helpers > Pool Purging). /// /// /// The retention model uses two settings: /// /// (default 0) - Absolute floor. Pools never purge below this. /// (default 2) - For active pools (accessed within IdleTimeoutSeconds), keep this many warm to avoid cold-start allocations. /// /// Effective floor = max(MinRetainCount, isActive ? WarmRetainCount : 0) /// /// /// >(options => { /// options.IdleTimeoutSeconds = 600f; // 10 minutes /// options.MinRetainCount = 10; /// options.WarmRetainCount = 5; /// }); /// /// // Configure all List types /// PoolPurgeSettings.ConfigureGeneric(typeof(List<>), options => { /// options.IdleTimeoutSeconds = 300f; /// }); /// /// // Disable for expensive objects /// PoolPurgeSettings.Disable(); /// ]]> /// /// public static class PoolPurgeSettings { /// /// Default idle timeout in seconds when intelligent purging is enabled. /// Set to 5 minutes by default to be conservative and avoid GC churn. /// public const float DefaultIdleTimeoutSeconds = 300f; /// /// Default minimum retain count during purge operations. /// This is the absolute floor - pools never purge below this, ever. /// public const int DefaultMinRetainCount = 0; /// /// Default warm retain count for active pools. /// Active pools (accessed within IdleTimeoutSeconds) keep this many items warm /// to avoid cold-start allocations. /// public const int DefaultWarmRetainCount = 2; /// /// Default buffer multiplier for comfortable pool size calculation. /// The comfortable size is calculated as max(effectiveMinRetain, rollingHighWaterMark * BufferMultiplier). /// public const float DefaultBufferMultiplier = 2.0f; /// /// Default rolling window duration in seconds for high water mark tracking. /// public const float DefaultRollingWindowSeconds = 300f; /// /// Default hysteresis duration in seconds. /// After a usage spike, purging is suppressed for this duration to prevent purge-allocate cycles. /// public const float DefaultHysteresisSeconds = 120f; /// /// Default spike threshold multiplier. /// A spike is detected when concurrent rentals exceed the rolling average by this factor. /// public const float DefaultSpikeThresholdMultiplier = 2.5f; /// /// Default maximum number of items to purge per operation. /// Limits GC pressure by spreading large purge operations across multiple calls. /// A value of 0 means unlimited (purge all eligible items in one operation). /// public const int DefaultMaxPurgesPerOperation = 10; /// /// Default maximum pool size (0 = unbounded). /// Pools exceeding this limit will have items purged. /// public const int DefaultMaxPoolSize = 0; /// /// Default Large Object Heap (LOH) threshold in bytes. /// Objects of this size or larger are allocated on the LOH and receive stricter purge policies. /// The .NET runtime uses 85,000 bytes as the LOH threshold. /// public const int DefaultLargeObjectThresholdBytes = 85000; /// /// Default buffer multiplier for large objects. /// Large objects use less buffer than regular objects to minimize LOH memory pressure. /// Default is 1.0 (no buffer) compared to 2.0 for regular objects. /// public const float DefaultLargeObjectBufferMultiplier = 1.0f; /// /// Default idle timeout multiplier for large objects. /// Large objects have shorter effective idle timeouts to be purged faster. /// Default is 0.5 (50% of normal timeout). /// public const float DefaultLargeObjectIdleTimeoutMultiplier = 0.5f; /// /// Default warm retain count for large objects. /// Large objects keep fewer warm items to minimize LOH memory usage. /// Default is 1 compared to 2 for regular objects. /// public const int DefaultLargeObjectWarmRetainCount = 1; private static int _globalEnabled = 0; private static int _purgeOnLowMemory = 1; private static int _purgeOnAppBackground = 1; private static int _purgeOnSceneUnload = 1; private static int _lifecycleHooksRegistered; private static float _defaultIdleTimeoutSeconds = DefaultIdleTimeoutSeconds; private static int _defaultMinRetainCount = DefaultMinRetainCount; private static int _defaultWarmRetainCount = DefaultWarmRetainCount; private static float _defaultBufferMultiplier = DefaultBufferMultiplier; private static float _defaultRollingWindowSeconds = DefaultRollingWindowSeconds; private static float _defaultHysteresisSeconds = DefaultHysteresisSeconds; private static float _defaultSpikeThresholdMultiplier = DefaultSpikeThresholdMultiplier; private static int _defaultMaxPurgesPerOperation = DefaultMaxPurgesPerOperation; private static int _defaultMaxPoolSize = DefaultMaxPoolSize; private static int _largeObjectThresholdBytes = DefaultLargeObjectThresholdBytes; private static float _largeObjectBufferMultiplier = DefaultLargeObjectBufferMultiplier; private static float _largeObjectIdleTimeoutMultiplier = DefaultLargeObjectIdleTimeoutMultiplier; private static int _largeObjectWarmRetainCount = DefaultLargeObjectWarmRetainCount; private static int _sizeAwarePoliciesEnabled = 1; private static readonly object ConfigLock = new object(); private static readonly Dictionary TypeConfigurations = new Dictionary(); private static readonly Dictionary GenericTypeConfigurations = new Dictionary(); private static readonly HashSet DisabledTypes = new HashSet(); // Settings-based per-type configurations (lower priority than programmatic API) private static readonly Dictionary SettingsTypeConfigurations = new Dictionary(); private static readonly Dictionary< Type, PoolPurgeTypeOptions > SettingsGenericTypeConfigurations = new Dictionary(); private static readonly HashSet SettingsDisabledTypes = new HashSet(); // Built-in type-aware defaults (lowest priority - applied before user configuration) private static readonly Dictionary BuiltInTypeConfigurations = new Dictionary(); private static readonly Dictionary< Type, PoolPurgeTypeOptions > BuiltInGenericTypeConfigurations = new Dictionary(); private static int _builtInDefaultsInitialized; // Cache for PoolPurgePolicyAttribute reflection results to avoid repeated reflection on the same type private static readonly Dictionary< Type, (bool HasAttribute, bool Enabled, PoolPurgeTypeOptions Options) > AttributeCache = new Dictionary(); /// /// Gets or sets whether intelligent pool purging is globally enabled. /// Default is false (disabled). Enable via Unity Editor settings or by setting this property. /// public static bool GlobalEnabled { get => Volatile.Read(ref _globalEnabled) != 0; set => Volatile.Write(ref _globalEnabled, value ? 1 : 0); } /// /// Gets or sets the default idle timeout in seconds for pools without type-specific configuration. /// Items idle longer than this are eligible for purging when intelligent purging is enabled. /// public static float DefaultGlobalIdleTimeoutSeconds { get => Volatile.Read(ref _defaultIdleTimeoutSeconds); set => Volatile.Write(ref _defaultIdleTimeoutSeconds, value); } /// /// Gets or sets the default minimum retain count for pools without type-specific configuration. /// This is the absolute floor - purge operations will never reduce the pool below this count. /// public static int DefaultGlobalMinRetainCount { get => Volatile.Read(ref _defaultMinRetainCount); set => Volatile.Write(ref _defaultMinRetainCount, value); } /// /// Gets or sets the default warm retain count for active pools. /// Active pools (accessed within ) keep this many items /// warm to avoid cold-start allocations. Idle pools purge to . /// Effective floor = max(MinRetainCount, isActive ? WarmRetainCount : 0). /// public static int DefaultGlobalWarmRetainCount { get => Volatile.Read(ref _defaultWarmRetainCount); set => Volatile.Write(ref _defaultWarmRetainCount, value); } /// /// Gets or sets the default buffer multiplier for comfortable pool size calculation. /// public static float DefaultGlobalBufferMultiplier { get => Volatile.Read(ref _defaultBufferMultiplier); set => Volatile.Write(ref _defaultBufferMultiplier, value); } /// /// Gets or sets the default rolling window duration in seconds for high water mark tracking. /// public static float DefaultGlobalRollingWindowSeconds { get => Volatile.Read(ref _defaultRollingWindowSeconds); set => Volatile.Write(ref _defaultRollingWindowSeconds, value); } /// /// Gets or sets the default hysteresis duration in seconds. /// public static float DefaultGlobalHysteresisSeconds { get => Volatile.Read(ref _defaultHysteresisSeconds); set => Volatile.Write(ref _defaultHysteresisSeconds, value); } /// /// Gets or sets the default spike threshold multiplier. /// public static float DefaultGlobalSpikeThresholdMultiplier { get => Volatile.Read(ref _defaultSpikeThresholdMultiplier); set => Volatile.Write(ref _defaultSpikeThresholdMultiplier, value); } /// /// Gets or sets the default maximum number of items to purge per operation. /// Limits GC pressure by spreading large purge operations across multiple calls. /// A value of 0 means unlimited (purge all eligible items in one operation). /// Negative values are normalized to 0 (unlimited). /// /// /// /// When set to a positive value, purge operations will process at most this many items /// before returning, setting a "pending purges" flag to continue on subsequent operations. /// This prevents GC spikes from bulk deallocation. /// /// /// The following operations bypass this limit and purge all eligible items immediately: /// /// Emergency purges via (triggered by ) /// Explicit Purge(reason) calls with a specified reason /// ForceFullPurge() method calls /// /// /// public static int DefaultGlobalMaxPurgesPerOperation { get => Volatile.Read(ref _defaultMaxPurgesPerOperation); set => Volatile.Write(ref _defaultMaxPurgesPerOperation, Math.Max(0, value)); } /// /// Gets or sets the default maximum pool size. /// Pools exceeding this limit will have items purged. /// A value of 0 means unbounded (no size limit). /// Negative values are normalized to 0 (unbounded). /// public static int DefaultGlobalMaxPoolSize { get => Volatile.Read(ref _defaultMaxPoolSize); set => Volatile.Write(ref _defaultMaxPoolSize, Math.Max(0, value)); } /// /// Gets or sets whether size-aware purge policies are enabled. /// When enabled, pools containing large objects (above ) /// automatically receive stricter purge policies. /// Default is true. /// /// /// /// Size-aware policies help manage Large Object Heap (LOH) memory pressure by: /// /// Using a smaller buffer multiplier for large objects (default 1.0x vs 2.0x) /// Reducing idle timeout for large objects (default 50% of normal) /// Keeping fewer warm items for large objects (default 1 vs 2) /// /// /// /// Set this to false if you want to manage large object pools manually or if /// automatic size estimation causes issues. /// /// public static bool SizeAwarePoliciesEnabled { get => Volatile.Read(ref _sizeAwarePoliciesEnabled) != 0; set => Volatile.Write(ref _sizeAwarePoliciesEnabled, value ? 1 : 0); } /// /// Gets or sets the threshold in bytes above which objects are considered "large objects" /// and receive stricter purge policies. /// Default is 85,000 bytes (the .NET Large Object Heap threshold). /// /// /// /// Objects of this size or larger are allocated on the .NET Large Object Heap (LOH). /// The LOH has different garbage collection characteristics: /// /// Only collected during Gen2 collections (expensive full GC) /// Not compacted by default (causes fragmentation) /// Retaining large pooled objects wastes significant memory /// /// /// /// For pools containing large objects, the purge system automatically applies: /// /// instead of /// Idle timeout multiplied by /// instead of /// /// /// public static int LargeObjectThresholdBytes { get => Volatile.Read(ref _largeObjectThresholdBytes); set => Volatile.Write(ref _largeObjectThresholdBytes, Math.Max(0, value)); } /// /// Gets or sets the buffer multiplier used for large objects. /// Large objects use a smaller buffer to minimize LOH memory pressure. /// Default is 1.0 (no buffer above peak usage). /// /// /// /// This value replaces for pools containing /// objects that exceed . A smaller buffer means /// pools are kept closer to their actual usage patterns, freeing LOH memory faster. /// /// public static float LargeObjectBufferMultiplier { get => Volatile.Read(ref _largeObjectBufferMultiplier); set => Volatile.Write(ref _largeObjectBufferMultiplier, Math.Max(0f, value)); } /// /// Gets or sets the multiplier applied to idle timeout for large objects. /// Large objects have shorter effective idle timeouts to be purged faster. /// Default is 0.5 (50% of normal timeout). /// /// /// /// This multiplier is applied to (or the /// type-specific idle timeout) for pools containing objects that exceed /// . A smaller multiplier means large objects /// are purged sooner after becoming idle. /// /// /// Example: If is 300 seconds and /// is 0.5, large objects become eligible /// for purging after 150 seconds of idle time. /// /// public static float LargeObjectIdleTimeoutMultiplier { get => Volatile.Read(ref _largeObjectIdleTimeoutMultiplier); set => Volatile.Write( ref _largeObjectIdleTimeoutMultiplier, Math.Max(0f, Math.Min(1f, value)) ); } /// /// Gets or sets the warm retain count for large object pools. /// Large object pools keep fewer warm items to minimize LOH memory usage. /// Default is 1 (compared to 2 for regular objects). /// /// /// /// This value replaces for pools containing /// objects that exceed . Keeping fewer warm items /// reduces LOH memory usage at the cost of potentially more allocations for bursty workloads. /// /// public static int LargeObjectWarmRetainCount { get => Volatile.Read(ref _largeObjectWarmRetainCount); set => Volatile.Write(ref _largeObjectWarmRetainCount, Math.Max(0, value)); } /// /// Gets or sets whether pools should be purged when is triggered. /// When enabled, an emergency purge is performed that ignores hysteresis and purges to . /// Default is true. /// public static bool PurgeOnLowMemory { get => Volatile.Read(ref _purgeOnLowMemory) != 0; set => Volatile.Write(ref _purgeOnLowMemory, value ? 1 : 0); } /// /// Gets or sets whether pools should be purged when the application loses focus (backgrounds). /// This is particularly useful on mobile platforms where backgrounded apps may be killed. /// When enabled, a normal purge is performed that respects hysteresis settings. /// Default is true. /// public static bool PurgeOnAppBackground { get => Volatile.Read(ref _purgeOnAppBackground) != 0; set => Volatile.Write(ref _purgeOnAppBackground, value ? 1 : 0); } /// /// Gets or sets whether pools should be purged when a scene is unloaded. /// When enabled, a purge check is triggered on all pools via . /// The purge respects hysteresis settings to avoid purge-allocate cycles during rapid scene transitions. /// Default is true. /// public static bool PurgeOnSceneUnload { get => Volatile.Read(ref _purgeOnSceneUnload) != 0; set => Volatile.Write(ref _purgeOnSceneUnload, value ? 1 : 0); } /// /// Configures intelligent purging options for a specific type. /// /// The type to configure. /// Action to configure the options. /// Thrown when configure is null. public static void Configure(Action configure) { if (configure == null) { throw new ArgumentNullException(nameof(configure)); } Type type = typeof(T); PoolPurgeTypeOptions options = new PoolPurgeTypeOptions(); configure(options); lock (ConfigLock) { TypeConfigurations[type] = options; DisabledTypes.Remove(type); } } /// /// Configures intelligent purging options for all types matching a generic type definition. /// /// The generic type definition (e.g., typeof(List<>)). /// Action to configure the options. /// Thrown when genericTypeDefinition or configure is null. /// Thrown when genericTypeDefinition is not a generic type definition. public static void ConfigureGeneric( Type genericTypeDefinition, Action configure ) { if (genericTypeDefinition == null) { throw new ArgumentNullException(nameof(genericTypeDefinition)); } if (configure == null) { throw new ArgumentNullException(nameof(configure)); } if (!genericTypeDefinition.IsGenericTypeDefinition) { throw new ArgumentException( "Type must be a generic type definition (e.g., typeof(List<>)).", nameof(genericTypeDefinition) ); } PoolPurgeTypeOptions options = new PoolPurgeTypeOptions(); configure(options); lock (ConfigLock) { GenericTypeConfigurations[genericTypeDefinition] = options; } } /// /// Disables intelligent purging for a specific type. /// /// The type to disable purging for. public static void Disable() { Type type = typeof(T); lock (ConfigLock) { DisabledTypes.Add(type); TypeConfigurations.Remove(type); } } /// /// Disables intelligent purging for a specific type. /// /// The type to disable purging for. /// Thrown when type is null. public static void Disable(Type type) { if (type == null) { throw new ArgumentNullException(nameof(type)); } lock (ConfigLock) { DisabledTypes.Add(type); TypeConfigurations.Remove(type); } } /// /// Enables intelligent purging for a specific type that was previously disabled. /// /// The type to enable purging for. public static void Enable() { Type type = typeof(T); lock (ConfigLock) { DisabledTypes.Remove(type); } } /// /// Enables intelligent purging for a specific type that was previously disabled. /// /// The type to enable purging for. /// Thrown when type is null. public static void Enable(Type type) { if (type == null) { throw new ArgumentNullException(nameof(type)); } lock (ConfigLock) { DisabledTypes.Remove(type); } } /// /// Removes all type-specific and generic type configurations. /// Does not affect global settings. /// public static void ClearTypeConfigurations() { lock (ConfigLock) { TypeConfigurations.Clear(); GenericTypeConfigurations.Clear(); DisabledTypes.Clear(); AttributeCache.Clear(); } } /// /// Clears all settings-based type configurations. /// This is typically called before reloading configurations from UnityHelpersSettings. /// public static void ClearSettingsTypeConfigurations() { lock (ConfigLock) { SettingsTypeConfigurations.Clear(); SettingsGenericTypeConfigurations.Clear(); SettingsDisabledTypes.Clear(); } } /// /// Clears all built-in type-aware default configurations and resets the initialization flag. /// This is primarily used for testing. /// internal static void ClearBuiltInTypeConfigurations() { lock (ConfigLock) { BuiltInTypeConfigurations.Clear(); BuiltInGenericTypeConfigurations.Clear(); } Volatile.Write(ref _builtInDefaultsInitialized, 0); } /// /// Ensures the built-in type-aware defaults are initialized. /// This method is called automatically when getting effective options. /// /// /// /// Built-in defaults provide sensible out-of-box behavior for common types: /// /// Arrays: Purge more aggressively (1.5x buffer, 3 minute idle timeout) /// StringBuilder: Short-lived temporaries (2 minute idle timeout, 1 min retain) /// List<>: Common collections kept warm (2x buffer, 2 min retain) /// Dictionary<,>: Common collections kept warm (2x buffer, 2 min retain) /// HashSet<>: Common collections kept warm (2x buffer, 2 min retain) /// Queue<>, Stack<>: Common collections kept warm /// /// /// /// These defaults have the lowest priority and are overridden by any user configuration. /// /// private static void EnsureBuiltInDefaultsInitialized() { if (Volatile.Read(ref _builtInDefaultsInitialized) != 0) { return; } lock (ConfigLock) { // Double-check inside lock if (Volatile.Read(ref _builtInDefaultsInitialized) != 0) { return; } InitializeBuiltInDefaults(); Volatile.Write(ref _builtInDefaultsInitialized, 1); } } /// /// Initializes the built-in type-aware defaults for common types. /// This method is called once during lazy initialization. /// private static void InitializeBuiltInDefaults() { // Arrays - generally larger memory footprint, purge more aggressively // Using typeof(Array) which will be checked in GetEffectiveOptions for all array types BuiltInTypeConfigurations[typeof(Array)] = new PoolPurgeTypeOptions { BufferMultiplier = 1.5f, IdleTimeoutSeconds = 180f, // 3 minutes }; // StringBuilder - often temporary, used for string building operations BuiltInTypeConfigurations[typeof(StringBuilder)] = new PoolPurgeTypeOptions { IdleTimeoutSeconds = 120f, // 2 minutes MinRetainCount = 1, }; // List<> - very common, keep warm for performance BuiltInGenericTypeConfigurations[typeof(List<>)] = new PoolPurgeTypeOptions { MinRetainCount = 2, BufferMultiplier = 2.0f, }; // Dictionary<,> - common, keep warm for performance BuiltInGenericTypeConfigurations[typeof(Dictionary<,>)] = new PoolPurgeTypeOptions { MinRetainCount = 2, BufferMultiplier = 2.0f, }; // HashSet<> - common, keep warm for performance BuiltInGenericTypeConfigurations[typeof(HashSet<>)] = new PoolPurgeTypeOptions { MinRetainCount = 2, BufferMultiplier = 2.0f, }; // Queue<> - keep a few warm BuiltInGenericTypeConfigurations[typeof(Queue<>)] = new PoolPurgeTypeOptions { MinRetainCount = 1, BufferMultiplier = 1.5f, }; // Stack<> - keep a few warm BuiltInGenericTypeConfigurations[typeof(Stack<>)] = new PoolPurgeTypeOptions { MinRetainCount = 1, BufferMultiplier = 1.5f, }; // LinkedList<> - less common, can purge more aggressively BuiltInGenericTypeConfigurations[typeof(LinkedList<>)] = new PoolPurgeTypeOptions { MinRetainCount = 1, BufferMultiplier = 1.5f, IdleTimeoutSeconds = 180f, // 3 minutes }; // SortedDictionary<,> - less common, can purge more aggressively BuiltInGenericTypeConfigurations[typeof(SortedDictionary<,>)] = new PoolPurgeTypeOptions { MinRetainCount = 1, BufferMultiplier = 1.5f, }; // SortedSet<> - less common, can purge more aggressively BuiltInGenericTypeConfigurations[typeof(SortedSet<>)] = new PoolPurgeTypeOptions { MinRetainCount = 1, BufferMultiplier = 1.5f, }; } /// /// Forces re-initialization of built-in type-aware defaults. /// This is primarily used for testing. /// internal static void ReinitializeBuiltInDefaults() { ClearBuiltInTypeConfigurations(); EnsureBuiltInDefaultsInitialized(); } /// /// Gets whether built-in type-aware defaults have been initialized. /// internal static bool BuiltInDefaultsInitialized => Volatile.Read(ref _builtInDefaultsInitialized) != 0; /// /// Configures settings-based per-type options. /// These have lower priority than programmatic API configurations. /// /// The type to configure. /// The configuration options. /// Thrown when type or options is null. public static void ConfigureFromSettings(Type type, PoolPurgeTypeOptions options) { if (type == null) { throw new ArgumentNullException(nameof(type)); } if (options == null) { throw new ArgumentNullException(nameof(options)); } lock (ConfigLock) { SettingsTypeConfigurations[type] = options; SettingsDisabledTypes.Remove(type); } } /// /// Configures settings-based per-type options for a generic type definition. /// These have lower priority than programmatic API configurations. /// /// The generic type definition (e.g., typeof(List<>)). /// The configuration options. /// Thrown when genericTypeDefinition or options is null. /// Thrown when genericTypeDefinition is not a generic type definition. public static void ConfigureGenericFromSettings( Type genericTypeDefinition, PoolPurgeTypeOptions options ) { if (genericTypeDefinition == null) { throw new ArgumentNullException(nameof(genericTypeDefinition)); } if (options == null) { throw new ArgumentNullException(nameof(options)); } if (!genericTypeDefinition.IsGenericTypeDefinition) { throw new ArgumentException( "Type must be a generic type definition (e.g., typeof(List<>)).", nameof(genericTypeDefinition) ); } lock (ConfigLock) { SettingsGenericTypeConfigurations[genericTypeDefinition] = options; } } /// /// Disables a type from settings-based configuration. /// /// The type to disable. /// Thrown when type is null. public static void DisableFromSettings(Type type) { if (type == null) { throw new ArgumentNullException(nameof(type)); } lock (ConfigLock) { SettingsDisabledTypes.Add(type); SettingsTypeConfigurations.Remove(type); } } /// /// Resets all settings to their default values. /// public static void ResetToDefaults() { GlobalEnabled = false; DefaultGlobalIdleTimeoutSeconds = DefaultIdleTimeoutSeconds; DefaultGlobalMinRetainCount = DefaultMinRetainCount; DefaultGlobalWarmRetainCount = DefaultWarmRetainCount; DefaultGlobalMaxPoolSize = DefaultMaxPoolSize; DefaultGlobalBufferMultiplier = DefaultBufferMultiplier; DefaultGlobalRollingWindowSeconds = DefaultRollingWindowSeconds; DefaultGlobalHysteresisSeconds = DefaultHysteresisSeconds; DefaultGlobalSpikeThresholdMultiplier = DefaultSpikeThresholdMultiplier; DefaultGlobalMaxPurgesPerOperation = DefaultMaxPurgesPerOperation; SizeAwarePoliciesEnabled = true; LargeObjectThresholdBytes = DefaultLargeObjectThresholdBytes; LargeObjectBufferMultiplier = DefaultLargeObjectBufferMultiplier; LargeObjectIdleTimeoutMultiplier = DefaultLargeObjectIdleTimeoutMultiplier; LargeObjectWarmRetainCount = DefaultLargeObjectWarmRetainCount; PurgeOnLowMemory = true; PurgeOnAppBackground = true; PurgeOnSceneUnload = true; ClearTypeConfigurations(); ClearSettingsTypeConfigurations(); } /// /// Disables intelligent pool purging globally. This is a convenience method for easy opt-out. /// Equivalent to setting GlobalEnabled = false. /// /// /// Call this method early in application initialization if you prefer to manage pool memory manually /// or if the automatic purging behavior is undesirable for your use case. /// /// /// /// // In your initialization code (e.g., RuntimeInitializeOnLoadMethod) /// PoolPurgeSettings.DisableGlobally(); /// /// public static void DisableGlobally() { GlobalEnabled = false; } /// /// Gets the effective configuration for a specific type, considering the hierarchy: /// specific type > generic type pattern > global defaults. /// /// The type to get configuration for. /// The effective purge configuration for the type. public static PoolPurgeEffectiveOptions GetEffectiveOptions() { return GetEffectiveOptions(typeof(T)); } /// /// Gets the effective configuration for a specific type, considering the hierarchy: /// /// Programmatic API (Configure, Disable) - highest priority /// UnityHelpersSettings per-type configuration /// PoolPurgePolicyAttribute on the type /// Generic type patterns by specificity (exact > inner open > outer open) /// Built-in type-aware defaults (for arrays, StringBuilder, common collections) /// Hardcoded global defaults - lowest priority /// /// /// The type to get configuration for. /// The effective purge configuration for the type. Returns global defaults if type is null. /// /// /// If is null, this method returns global default options rather than throwing. /// This follows the defensive programming principle of handling all inputs gracefully. /// /// /// For generic types like List<List<int>>, patterns are matched in order of specificity: /// /// List<List<int>> - exact match /// List<List<>> - inner generic open /// List<> - outer generic open /// /// /// /// Built-in type-aware defaults provide sensible out-of-box behavior for common types: /// /// Arrays: Purge more aggressively (1.5x buffer, 3 minute idle timeout) /// StringBuilder: Short-lived temporaries (2 minute idle timeout, 1 min retain) /// List<>, Dictionary<,>, HashSet<>: Common collections kept warm (2x buffer, 2 min retain) /// /// /// public static PoolPurgeEffectiveOptions GetEffectiveOptions(Type type) { if (type == null) { // Defensive: return global defaults rather than throwing for null input return new PoolPurgeEffectiveOptions( enabled: GlobalEnabled, idleTimeoutSeconds: DefaultGlobalIdleTimeoutSeconds, minRetainCount: DefaultGlobalMinRetainCount, warmRetainCount: DefaultGlobalWarmRetainCount, bufferMultiplier: DefaultGlobalBufferMultiplier, rollingWindowSeconds: DefaultGlobalRollingWindowSeconds, hysteresisSeconds: DefaultGlobalHysteresisSeconds, spikeThresholdMultiplier: DefaultGlobalSpikeThresholdMultiplier, maxPurgesPerOperation: DefaultGlobalMaxPurgesPerOperation, maxPoolSize: DefaultGlobalMaxPoolSize, source: PoolPurgeConfigurationSource.GlobalDefaults ); } // Ensure built-in defaults are initialized EnsureBuiltInDefaultsInitialized(); bool globalEnabled = GlobalEnabled; bool typeDisabled; bool settingsTypeDisabled; PoolPurgeTypeOptions typeOptions = null; PoolPurgeTypeOptions settingsTypeOptions = null; PoolPurgeTypeOptions builtInTypeOptions = null; PoolPurgeTypeOptions bestProgrammaticGenericOptions = null; PoolPurgeTypeOptions bestSettingsGenericOptions = null; PoolPurgeTypeOptions bestBuiltInGenericOptions = null; int bestProgrammaticGenericPriority = int.MaxValue; int bestSettingsGenericPriority = int.MaxValue; int bestBuiltInGenericPriority = int.MaxValue; lock (ConfigLock) { // Check programmatic disabled first (highest priority) typeDisabled = DisabledTypes.Contains(type); settingsTypeDisabled = SettingsDisabledTypes.Contains(type); if (!typeDisabled) { // Programmatic type-specific configuration TypeConfigurations.TryGetValue(type, out typeOptions); // Settings-based type-specific configuration (lower priority) SettingsTypeConfigurations.TryGetValue(type, out settingsTypeOptions); // Built-in type-specific configuration (lowest priority) BuiltInTypeConfigurations.TryGetValue(type, out builtInTypeOptions); // For generic types, find the best matching pattern by specificity if (type.IsGenericType) { // Get all possible patterns for this type in order of specificity foreach (Type pattern in PoolTypeResolver.GetAllMatchingPatterns(type)) { // Skip the exact type (already handled above) if (pattern == type) { continue; } // Check programmatic generic configurations if ( GenericTypeConfigurations.TryGetValue( pattern, out PoolPurgeTypeOptions programmaticOptions ) ) { int priority = PoolTypeResolver.GetMatchPriority(type, pattern); if (priority < bestProgrammaticGenericPriority) { bestProgrammaticGenericPriority = priority; bestProgrammaticGenericOptions = programmaticOptions; } } // Check settings-based generic configurations if ( SettingsGenericTypeConfigurations.TryGetValue( pattern, out PoolPurgeTypeOptions settingsOptions ) ) { int priority = PoolTypeResolver.GetMatchPriority(type, pattern); if (priority < bestSettingsGenericPriority) { bestSettingsGenericPriority = priority; bestSettingsGenericOptions = settingsOptions; } } // Check built-in generic configurations (lowest priority) if ( BuiltInGenericTypeConfigurations.TryGetValue( pattern, out PoolPurgeTypeOptions builtInOptions ) ) { int priority = PoolTypeResolver.GetMatchPriority(type, pattern); if (priority < bestBuiltInGenericPriority) { bestBuiltInGenericPriority = priority; bestBuiltInGenericOptions = builtInOptions; } } } } // For arrays, check if we have a built-in configuration for Array if (type.IsArray && builtInTypeOptions == null) { BuiltInTypeConfigurations.TryGetValue( typeof(Array), out builtInTypeOptions ); } } } // 1. Programmatic disabled (highest priority for disabling) if (typeDisabled) { return new PoolPurgeEffectiveOptions( enabled: false, idleTimeoutSeconds: 0f, minRetainCount: 0, warmRetainCount: 0, bufferMultiplier: DefaultBufferMultiplier, rollingWindowSeconds: DefaultRollingWindowSeconds, hysteresisSeconds: DefaultHysteresisSeconds, spikeThresholdMultiplier: DefaultSpikeThresholdMultiplier, maxPurgesPerOperation: DefaultMaxPurgesPerOperation, maxPoolSize: DefaultMaxPoolSize, source: PoolPurgeConfigurationSource.TypeDisabled ); } // 2. Programmatic type-specific configuration if (typeOptions != null) { return BuildEffectiveOptions( typeOptions, PoolPurgeConfigurationSource.TypeSpecific ); } // 3. Settings-based per-type configuration if (settingsTypeOptions != null) { return BuildEffectiveOptions( settingsTypeOptions, PoolPurgeConfigurationSource.UnityHelpersSettingsPerType ); } // 4. Settings-based disabled if (settingsTypeDisabled) { return new PoolPurgeEffectiveOptions( enabled: false, idleTimeoutSeconds: 0f, minRetainCount: 0, warmRetainCount: 0, bufferMultiplier: DefaultBufferMultiplier, rollingWindowSeconds: DefaultRollingWindowSeconds, hysteresisSeconds: DefaultHysteresisSeconds, spikeThresholdMultiplier: DefaultSpikeThresholdMultiplier, maxPurgesPerOperation: DefaultMaxPurgesPerOperation, maxPoolSize: DefaultMaxPoolSize, source: PoolPurgeConfigurationSource.UnityHelpersSettingsPerType ); } // 5. PoolPurgePolicyAttribute on the type bool hasTypeAttribute = HasPoolPurgePolicyAttribute( type, out bool attributeEnabled, out PoolPurgeTypeOptions attributeOptions ); if (hasTypeAttribute) { if (!attributeEnabled) { return new PoolPurgeEffectiveOptions( enabled: false, idleTimeoutSeconds: 0f, minRetainCount: 0, warmRetainCount: 0, bufferMultiplier: DefaultBufferMultiplier, rollingWindowSeconds: DefaultRollingWindowSeconds, hysteresisSeconds: DefaultHysteresisSeconds, spikeThresholdMultiplier: DefaultSpikeThresholdMultiplier, maxPurgesPerOperation: DefaultMaxPurgesPerOperation, maxPoolSize: DefaultMaxPoolSize, source: PoolPurgeConfigurationSource.Attribute ); } return BuildEffectiveOptions( attributeOptions, PoolPurgeConfigurationSource.Attribute ); } // 6. Programmatic generic pattern (best match by specificity) if (bestProgrammaticGenericOptions != null) { return BuildEffectiveOptions( bestProgrammaticGenericOptions, PoolPurgeConfigurationSource.GenericPattern ); } // 7. Settings-based generic pattern (best match by specificity) if (bestSettingsGenericOptions != null) { return BuildEffectiveOptions( bestSettingsGenericOptions, PoolPurgeConfigurationSource.UnityHelpersSettingsPerType ); } // 8. Built-in type-specific defaults (lowest priority tier) if (builtInTypeOptions != null) { return BuildEffectiveOptions( builtInTypeOptions, PoolPurgeConfigurationSource.BuiltInDefaults ); } // 9. Built-in generic pattern defaults (lowest priority tier) if (bestBuiltInGenericOptions != null) { return BuildEffectiveOptions( bestBuiltInGenericOptions, PoolPurgeConfigurationSource.BuiltInDefaults ); } // 10. Global defaults return GetGlobalDefaultEffectiveOptions(); } /// /// Gets the global default effective options with no type-specific configuration. /// /// The global default purge configuration. private static PoolPurgeEffectiveOptions GetGlobalDefaultEffectiveOptions() { return new PoolPurgeEffectiveOptions( enabled: GlobalEnabled, idleTimeoutSeconds: DefaultGlobalIdleTimeoutSeconds, minRetainCount: DefaultGlobalMinRetainCount, warmRetainCount: DefaultGlobalWarmRetainCount, bufferMultiplier: DefaultGlobalBufferMultiplier, rollingWindowSeconds: DefaultGlobalRollingWindowSeconds, hysteresisSeconds: DefaultGlobalHysteresisSeconds, spikeThresholdMultiplier: DefaultGlobalSpikeThresholdMultiplier, maxPurgesPerOperation: DefaultGlobalMaxPurgesPerOperation, maxPoolSize: DefaultGlobalMaxPoolSize, source: PoolPurgeConfigurationSource.GlobalDefaults ); } /// /// Checks if intelligent purging is enabled for a specific type. /// /// The type to check. /// true if intelligent purging is enabled; otherwise, false. public static bool IsEnabled() { return GetEffectiveOptions().Enabled; } /// /// Checks if intelligent purging is enabled for a specific type. /// /// The type to check. /// true if intelligent purging is enabled; otherwise, false. /// Thrown when type is null. public static bool IsEnabled(Type type) { return GetEffectiveOptions(type).Enabled; } /// /// Gets the effective configuration for a specific type, with size-aware policy adjustments. /// Large objects (above ) receive stricter policies. /// /// The type to get configuration for. /// The effective purge configuration for the type, adjusted for size. /// /// /// When is true and the type is estimated to /// exceed , the returned options are adjusted: /// /// is reduced to /// is multiplied by /// is reduced to /// /// /// public static PoolPurgeEffectiveOptions GetSizeAwareEffectiveOptions() { return GetSizeAwareEffectiveOptions(typeof(T)); } /// /// Gets the effective configuration for a specific type, with size-aware policy adjustments. /// Large objects (above ) receive stricter policies. /// /// The type to get configuration for. /// The effective purge configuration for the type, adjusted for size. /// /// /// If is null, returns the result of /// with a null type (which uses global defaults). /// /// /// When is true and the type is estimated to /// exceed , the returned options are adjusted: /// /// is reduced to /// is multiplied by /// is reduced to /// /// /// public static PoolPurgeEffectiveOptions GetSizeAwareEffectiveOptions(Type type) { if (type == null) { return GetGlobalDefaultEffectiveOptions(); } PoolPurgeEffectiveOptions baseOptions = GetEffectiveOptions(type); // If size-aware policies are disabled, return base options if (!SizeAwarePoliciesEnabled) { return baseOptions; } // Check if this type is a large object int estimatedSize = PoolSizeEstimator.EstimateItemSizeBytes(type); int threshold = LargeObjectThresholdBytes; if (estimatedSize < threshold) { return baseOptions; } // Apply large object adjustments float adjustedIdleTimeout = baseOptions.IdleTimeoutSeconds * LargeObjectIdleTimeoutMultiplier; float adjustedBufferMultiplier = LargeObjectBufferMultiplier; int adjustedWarmRetainCount = LargeObjectWarmRetainCount; // Use the smaller of the configured and large-object values if (baseOptions.BufferMultiplier < adjustedBufferMultiplier) { adjustedBufferMultiplier = baseOptions.BufferMultiplier; } if (baseOptions.WarmRetainCount < adjustedWarmRetainCount) { adjustedWarmRetainCount = baseOptions.WarmRetainCount; } return new PoolPurgeEffectiveOptions( enabled: baseOptions.Enabled, idleTimeoutSeconds: adjustedIdleTimeout, minRetainCount: baseOptions.MinRetainCount, warmRetainCount: adjustedWarmRetainCount, bufferMultiplier: adjustedBufferMultiplier, rollingWindowSeconds: baseOptions.RollingWindowSeconds, hysteresisSeconds: baseOptions.HysteresisSeconds, spikeThresholdMultiplier: baseOptions.SpikeThresholdMultiplier, maxPurgesPerOperation: baseOptions.MaxPurgesPerOperation, maxPoolSize: baseOptions.MaxPoolSize, source: baseOptions.Source ); } /// /// Checks if a type is considered a large object based on its estimated size. /// /// The type to check. /// true if the type's estimated size exceeds ; otherwise, false. public static bool IsLargeObject() { return IsLargeObject(typeof(T)); } /// /// Checks if a type is considered a large object based on its estimated size. /// /// The type to check. /// true if the type's estimated size exceeds ; otherwise, false. /// /// Returns false if is null. /// public static bool IsLargeObject(Type type) { if (type == null) { return false; } int estimatedSize = PoolSizeEstimator.EstimateItemSizeBytes(type); return estimatedSize >= LargeObjectThresholdBytes; } private static PoolPurgeEffectiveOptions BuildEffectiveOptions( PoolPurgeTypeOptions options, PoolPurgeConfigurationSource source ) { bool enabled = options.Enabled ?? GlobalEnabled; float idleTimeout = options.IdleTimeoutSeconds ?? DefaultGlobalIdleTimeoutSeconds; int minRetain = options.MinRetainCount ?? DefaultGlobalMinRetainCount; int warmRetain = options.WarmRetainCount ?? DefaultGlobalWarmRetainCount; float buffer = options.BufferMultiplier ?? DefaultGlobalBufferMultiplier; float window = options.RollingWindowSeconds ?? DefaultGlobalRollingWindowSeconds; float hysteresis = options.HysteresisSeconds ?? DefaultGlobalHysteresisSeconds; float spike = options.SpikeThresholdMultiplier ?? DefaultGlobalSpikeThresholdMultiplier; int maxPurges = options.MaxPurgesPerOperation ?? DefaultGlobalMaxPurgesPerOperation; int maxPoolSize = options.MaxPoolSize ?? DefaultGlobalMaxPoolSize; return new PoolPurgeEffectiveOptions( enabled, idleTimeout, minRetain, warmRetain, buffer, window, hysteresis, spike, maxPurges, maxPoolSize, source ); } private static bool HasPoolPurgePolicyAttribute( Type type, out bool enabled, out PoolPurgeTypeOptions attributeOptions ) { // Check cache first to avoid repeated reflection on the same type lock (ConfigLock) { if ( AttributeCache.TryGetValue( type, out (bool HasAttribute, bool Enabled, PoolPurgeTypeOptions Options) cached ) ) { enabled = cached.Enabled; attributeOptions = cached.Options; return cached.HasAttribute; } } // Perform reflection (outside lock to minimize contention) PoolPurgePolicyAttribute attribute = type.GetCustomAttribute(); bool hasAttribute; bool attributeEnabled; PoolPurgeTypeOptions options; if (attribute == null) { hasAttribute = false; attributeEnabled = true; options = null; } else { hasAttribute = true; attributeEnabled = attribute.Enabled; options = new PoolPurgeTypeOptions { Enabled = attribute.Enabled, IdleTimeoutSeconds = attribute.IdleTimeoutSeconds, MinRetainCount = attribute.MinRetainCount, WarmRetainCount = attribute.WarmRetainCount, }; } // Cache the result lock (ConfigLock) { AttributeCache[type] = (hasAttribute, attributeEnabled, options); } enabled = attributeEnabled; attributeOptions = options; return hasAttribute; } /// /// Registers application lifecycle hooks for automatic pool purging. /// This method is automatically called during runtime initialization via . /// /// /// /// This method registers handlers for the following Unity events: /// /// - Triggers emergency purge when the system is low on memory /// - Triggers purge when the app loses focus (backgrounds on mobile) /// - Triggers purge when a scene is unloaded /// /// /// /// The method is idempotent - calling it multiple times has no additional effect. /// /// [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)] internal static void RegisterLifecycleHooks() { if (Interlocked.Exchange(ref _lifecycleHooksRegistered, 1) != 0) { return; } Application.lowMemory += OnLowMemory; Application.focusChanged += OnFocusChanged; SceneManager.sceneUnloaded += OnSceneUnloaded; } /// /// Unregisters application lifecycle hooks. Primarily used for testing. /// internal static void UnregisterLifecycleHooks() { if (Interlocked.Exchange(ref _lifecycleHooksRegistered, 0) == 0) { return; } Application.lowMemory -= OnLowMemory; Application.focusChanged -= OnFocusChanged; SceneManager.sceneUnloaded -= OnSceneUnloaded; } private static void OnLowMemory() { if (!PurgeOnLowMemory) { return; } // Use ForceFullPurgeAll to bypass MaxPurgesPerOperation limits during memory pressure. // This ensures all eligible items are purged immediately when the system is low on memory. GlobalPoolRegistry.ForceFullPurgeAll( respectHysteresis: false, reason: PurgeReason.MemoryPressure ); } private static void OnFocusChanged(bool hasFocus) { if (hasFocus || !PurgeOnAppBackground) { return; } PurgeAllPools(respectHysteresis: true, reason: PurgeReason.AppBackgrounded); } /// /// Handles scene unloaded events by triggering a purge on all pools. /// /// /// The scene that was unloaded. This parameter is unused because purge operations /// are global (affecting all pools) rather than scene-specific. The parameter is /// required by the delegate signature. /// /// /// /// When a scene is unloaded, pooled objects that were primarily used in that scene /// may no longer be needed. This handler triggers a purge check on all pools to /// reclaim memory from items that are no longer actively used. /// /// /// The purge respects hysteresis settings to avoid purge-allocate cycles during /// rapid scene transitions. Set to false /// to disable this behavior. /// /// private static void OnSceneUnloaded(Scene scene) { // Scene parameter unused: purge is global across all pools, not scene-specific. // The parameter is required by the SceneManager.sceneUnloaded delegate signature. _ = scene; if (!PurgeOnSceneUnload) { return; } PurgeAllPools(respectHysteresis: true, reason: PurgeReason.SceneUnloaded); } /// /// Purges all registered pools. /// /// /// If true, pools in their hysteresis period (after a usage spike) will skip purging. /// If false, all pools are purged regardless of hysteresis state (emergency purge). /// /// The reason for purging (used in callbacks and statistics). /// The total number of items purged across all pools. public static int PurgeAllPools(bool respectHysteresis, PurgeReason reason) { return GlobalPoolRegistry.PurgeAll(respectHysteresis, reason); } /// /// Purges all registered pools with the reason. /// /// The total number of items purged across all pools. public static int PurgeAllPools() { return PurgeAllPools(respectHysteresis: true, reason: PurgeReason.Explicit); } } /// /// Global registry for all pool instances, enabling cross-pool operations like purge-all /// and global memory budget enforcement. /// /// /// /// Pools automatically register themselves when created and unregister when disposed. /// The registry uses weak references to avoid preventing pool garbage collection. /// /// /// The global memory budget feature prevents aggregate memory bloat by tracking the total /// number of pooled items across all pools and purging from least-recently-used pools when /// the budget is exceeded. Use to configure the maximum /// allowed items and to trigger purging. /// /// public static class GlobalPoolRegistry { /// /// Default maximum number of pooled items across all pools (50,000). /// public const long DefaultGlobalMaxPooledItems = 50000; /// /// Default interval in seconds between automatic budget enforcement checks. /// public const float DefaultBudgetEnforcementIntervalSeconds = 30f; /// /// Interface for pools that can be purged via the global registry. /// public interface IPurgeable { /// /// Purges items from the pool with the specified reason. /// /// The reason for purging. /// /// If true, bypasses the hysteresis check and purges immediately. /// Used for emergency purges (e.g., low memory situations). /// /// The number of items purged. /// /// This method purges all eligible items without respecting MaxPurgesPerOperation limits. /// It is treated as an explicit cleanup operation similar to . /// int Purge(PurgeReason reason, bool ignoreHysteresis = false); /// /// Forces a full purge with a specified reason, bypassing MaxPurgesPerOperation limits. /// /// The reason for purging (used in callbacks). /// /// If true, bypasses the hysteresis check and purges immediately. /// Used for emergency purges (e.g., low memory situations). /// /// The number of items purged. int ForceFullPurge(PurgeReason reason, bool ignoreHysteresis = false); } /// /// Interface for pools that provide statistics for global budget tracking. /// Extends with the ability to report current size and last access time. /// public interface IPoolStatistics : IPurgeable { /// /// Gets the current number of items in the pool. /// int CurrentPooledCount { get; } /// /// Gets the time (in seconds since pool creation or epoch) when the pool was last accessed. /// Used for LRU-based purging when the global budget is exceeded. /// float LastAccessTime { get; } /// /// Purges a specific number of items from the pool for budget enforcement. /// /// The maximum number of items to purge. /// The actual number of items purged (may be less than requested if pool has fewer items). /// /// This method is called by to reduce pool size. /// It respects and will not purge below that threshold. /// int PurgeForBudget(int count); } private static readonly object RegistryLock = new object(); private static readonly List> RegisteredPools = new List>(); private static readonly List BudgetEnforcementPools = new List(); private static long _globalMaxPooledItems = DefaultGlobalMaxPooledItems; private static int _budgetEnforcementEnabled = 1; private static float _budgetEnforcementIntervalSeconds = DefaultBudgetEnforcementIntervalSeconds; private static float _lastBudgetEnforcementTime; private static readonly System.Diagnostics.Stopwatch RegistryStopwatch = System.Diagnostics.Stopwatch.StartNew(); /// /// Gets or sets the global maximum number of pooled items across all pools. /// When the total exceeds this limit, purges from least-recently-used pools. /// A value of 0 or less disables budget enforcement. /// Default is 50,000. /// public static long GlobalMaxPooledItems { get => Volatile.Read(ref _globalMaxPooledItems); set => Volatile.Write(ref _globalMaxPooledItems, value); } /// /// Gets or sets whether automatic budget enforcement is enabled. /// When enabled, is called periodically during pool operations. /// Default is true. /// public static bool BudgetEnforcementEnabled { get => Volatile.Read(ref _budgetEnforcementEnabled) != 0; set => Volatile.Write(ref _budgetEnforcementEnabled, value ? 1 : 0); } /// /// Gets or sets the interval in seconds between automatic budget enforcement checks. /// Default is 30 seconds. /// public static float BudgetEnforcementIntervalSeconds { get => Volatile.Read(ref _budgetEnforcementIntervalSeconds); set => Volatile.Write( ref _budgetEnforcementIntervalSeconds, value > 0f ? value : DefaultBudgetEnforcementIntervalSeconds ); } /// /// Gets the current total number of pooled items across all registered pools. /// /// /// This property iterates through all registered pools to calculate the total. /// For pools that don't implement , their items are not counted. /// Dead pool references are cleaned up during iteration. /// public static long CurrentTotalPooledItems { get { long total = 0; lock (RegistryLock) { for (int i = RegisteredPools.Count - 1; i >= 0; i--) { if (!RegisteredPools[i].TryGetTarget(out IPurgeable pool)) { RegisteredPools.RemoveAt(i); continue; } if (pool is IPoolStatistics stats) { total += stats.CurrentPooledCount; } } } return total; } } /// /// Gets the current number of registered pools (including potentially collected ones). /// This count may be higher than the actual number of live pools. /// public static int RegisteredCount { get { lock (RegistryLock) { return RegisteredPools.Count; } } } /// /// Registers a pool with the global registry. /// /// The pool to register. public static void Register(IPurgeable pool) { if (pool == null) { return; } lock (RegistryLock) { RegisteredPools.Add(new WeakReference(pool)); } } /// /// Unregisters a pool from the global registry. /// /// The pool to unregister. public static void Unregister(IPurgeable pool) { if (pool == null) { return; } lock (RegistryLock) { for (int i = RegisteredPools.Count - 1; i >= 0; i--) { if ( !RegisteredPools[i].TryGetTarget(out IPurgeable target) || ReferenceEquals(target, pool) ) { RegisteredPools.RemoveAt(i); } } } } /// /// Purges all registered pools. /// /// /// If true, pools in their hysteresis period will skip purging. /// If false, all pools are purged regardless of hysteresis state (emergency purge). /// /// The reason for purging. /// The total number of items purged across all pools. public static int PurgeAll(bool respectHysteresis, PurgeReason reason) { int totalPurged = 0; bool ignoreHysteresis = !respectHysteresis; // Copy pool references while holding lock, then purge outside lock // to avoid holding lock during potentially slow purge operations using PooledResource> pooled = Buffers.List.Get( out List poolsToPurge ); lock (RegistryLock) { for (int i = RegisteredPools.Count - 1; i >= 0; i--) { if (RegisteredPools[i].TryGetTarget(out IPurgeable pool)) { poolsToPurge.Add(pool); } else { // Clean up dead references RegisteredPools.RemoveAt(i); } } } for (int i = 0; i < poolsToPurge.Count; i++) { try { totalPurged += poolsToPurge[i].Purge(reason, ignoreHysteresis); } catch (Exception e) { // Swallow exceptions from individual pools to ensure all pools are attempted #if UNITY_EDITOR || DEVELOPMENT_BUILD Debug.LogWarning($"[PoolPurgeSettings] Failed to purge pool: {e.Message}"); #endif _ = e; } } return totalPurged; } /// /// Forces a full purge on all registered pools, bypassing MaxPurgesPerOperation limits. /// /// /// If true, pools in their hysteresis period will skip purging. /// If false, all pools are purged regardless of hysteresis state (emergency purge). /// /// The reason for purging. /// The total number of items purged across all pools. /// /// This method is used for emergency purges (e.g., ) /// where immediate memory reclamation is critical and gradual purging limits should be bypassed. /// public static int ForceFullPurgeAll(bool respectHysteresis, PurgeReason reason) { int totalPurged = 0; bool ignoreHysteresis = !respectHysteresis; // Copy pool references while holding lock, then purge outside lock // to avoid holding lock during potentially slow purge operations using PooledResource> pooled = Buffers.List.Get( out List poolsToPurge ); lock (RegistryLock) { for (int i = RegisteredPools.Count - 1; i >= 0; i--) { if (RegisteredPools[i].TryGetTarget(out IPurgeable pool)) { poolsToPurge.Add(pool); } else { // Clean up dead references RegisteredPools.RemoveAt(i); } } } for (int i = 0; i < poolsToPurge.Count; i++) { try { totalPurged += poolsToPurge[i].ForceFullPurge(reason, ignoreHysteresis); } catch (Exception e) { // Swallow exceptions from individual pools to ensure all pools are attempted #if UNITY_EDITOR || DEVELOPMENT_BUILD Debug.LogWarning( $"[PoolPurgeSettings] Failed to force-purge pool: {e.Message}" ); #endif _ = e; } } return totalPurged; } /// /// Enforces the global pool budget by purging from least-recently-used pools. /// /// The total number of items purged across all pools. /// /// /// This method calculates the total pooled items across all registered pools and, /// if the total exceeds , purges items from the /// least-recently-used pools until the budget is satisfied. /// /// /// Pools are sorted by (ascending) /// and items are purged starting from the oldest pools. Each pool's /// is respected. /// /// public static int EnforceBudget() { long maxItems = GlobalMaxPooledItems; if (maxItems <= 0) { return 0; } long currentTotal = 0; int totalPurged = 0; lock (RegistryLock) { BudgetEnforcementPools.Clear(); for (int i = RegisteredPools.Count - 1; i >= 0; i--) { if (!RegisteredPools[i].TryGetTarget(out IPurgeable pool)) { RegisteredPools.RemoveAt(i); continue; } if (pool is IPoolStatistics stats) { BudgetEnforcementPools.Add(stats); currentTotal += stats.CurrentPooledCount; } } if (currentTotal <= maxItems) { BudgetEnforcementPools.Clear(); return 0; } long excess = currentTotal - maxItems; SortPoolsByLastAccessTime(BudgetEnforcementPools); long remaining = excess; for (int i = 0; i < BudgetEnforcementPools.Count && remaining > 0; i++) { IPoolStatistics pool = BudgetEnforcementPools[i]; int poolCount = pool.CurrentPooledCount; if (poolCount <= 0) { continue; } int toPurge = (int)System.Math.Min(remaining, poolCount); if (toPurge <= 0) { continue; } try { int purged = pool.PurgeForBudget(toPurge); totalPurged += purged; remaining -= purged; } catch (Exception e) { // Swallow exceptions to continue with other pools #if UNITY_EDITOR || DEVELOPMENT_BUILD Debug.LogWarning( $"[PoolPurgeSettings] Failed to purge pool for budget: {e.Message}" ); #endif _ = e; } } BudgetEnforcementPools.Clear(); } return totalPurged; } /// /// Tries to enforce the budget if the enforcement interval has elapsed. /// Called automatically during pool operations when is true. /// /// The number of items purged, or 0 if enforcement was not needed or not due. public static int TryEnforceBudgetIfNeeded() { if (!BudgetEnforcementEnabled) { return 0; } long maxItems = GlobalMaxPooledItems; if (maxItems <= 0) { return 0; } float currentTime = (float)RegistryStopwatch.Elapsed.TotalSeconds; float interval = BudgetEnforcementIntervalSeconds; float lastEnforcement = Volatile.Read(ref _lastBudgetEnforcementTime); if (currentTime - lastEnforcement < interval) { return 0; } float original = Interlocked.CompareExchange( ref _lastBudgetEnforcementTime, currentTime, lastEnforcement ); if (original != lastEnforcement) { return 0; } return EnforceBudget(); } /// /// Gets a snapshot of global pool statistics. /// /// A snapshot of current global pool metrics. public static GlobalPoolStatistics GetStatistics() { int livePoolCount = 0; int statsPoolCount = 0; long totalItems = 0; float oldestAccessTime = float.MaxValue; float newestAccessTime = float.MinValue; lock (RegistryLock) { for (int i = RegisteredPools.Count - 1; i >= 0; i--) { if (!RegisteredPools[i].TryGetTarget(out IPurgeable pool)) { RegisteredPools.RemoveAt(i); continue; } livePoolCount++; if (pool is IPoolStatistics stats) { statsPoolCount++; totalItems += stats.CurrentPooledCount; float accessTime = stats.LastAccessTime; if (accessTime < oldestAccessTime) { oldestAccessTime = accessTime; } if (accessTime > newestAccessTime) { newestAccessTime = accessTime; } } } } if (statsPoolCount == 0) { oldestAccessTime = 0f; newestAccessTime = 0f; } return new GlobalPoolStatistics( livePoolCount, statsPoolCount, totalItems, GlobalMaxPooledItems, oldestAccessTime, newestAccessTime ); } /// /// Resets all budget-related settings to their default values. /// public static void ResetBudgetSettings() { GlobalMaxPooledItems = DefaultGlobalMaxPooledItems; BudgetEnforcementEnabled = true; BudgetEnforcementIntervalSeconds = DefaultBudgetEnforcementIntervalSeconds; Volatile.Write(ref _lastBudgetEnforcementTime, 0f); } /// /// Clears all registered pools from the registry. /// This does not dispose or purge the pools, only removes their registrations. /// Primarily used for testing. /// internal static void Clear() { lock (RegistryLock) { RegisteredPools.Clear(); } Volatile.Write(ref _lastBudgetEnforcementTime, 0f); } private static void SortPoolsByLastAccessTime(List pools) { int count = pools.Count; for (int i = 1; i < count; i++) { IPoolStatistics key = pools[i]; float keyTime = key.LastAccessTime; int j = i - 1; while (j >= 0 && pools[j].LastAccessTime > keyTime) { pools[j + 1] = pools[j]; j--; } pools[j + 1] = key; } } } /// /// Immutable snapshot of global pool registry statistics. /// public readonly struct GlobalPoolStatistics { /// /// Gets the number of live (non-collected) registered pools. /// public int LivePoolCount { get; } /// /// Gets the number of pools that implement . /// public int StatisticsPoolCount { get; } /// /// Gets the total number of pooled items across all statistics-enabled pools. /// public long TotalPooledItems { get; } /// /// Gets the configured global maximum pooled items budget. /// public long GlobalMaxPooledItems { get; } /// /// Gets the last access time of the oldest pool (earliest ). /// public float OldestPoolAccessTime { get; } /// /// Gets the last access time of the newest pool (latest ). /// public float NewestPoolAccessTime { get; } /// /// Gets the ratio of current pooled items to the budget. /// A value greater than 1.0 indicates the budget is exceeded. /// public float BudgetUtilization => GlobalMaxPooledItems > 0 ? (float)TotalPooledItems / GlobalMaxPooledItems : 0f; /// /// Gets whether the current total exceeds the configured budget. /// public bool IsBudgetExceeded => GlobalMaxPooledItems > 0 && TotalPooledItems > GlobalMaxPooledItems; /// /// Creates a new global pool statistics snapshot. /// public GlobalPoolStatistics( int livePoolCount, int statisticsPoolCount, long totalPooledItems, long globalMaxPooledItems, float oldestPoolAccessTime, float newestPoolAccessTime ) { LivePoolCount = livePoolCount; StatisticsPoolCount = statisticsPoolCount; TotalPooledItems = totalPooledItems; GlobalMaxPooledItems = globalMaxPooledItems; OldestPoolAccessTime = oldestPoolAccessTime; NewestPoolAccessTime = newestPoolAccessTime; } /// public override string ToString() { return $"GlobalPoolStatistics(LivePools={LivePoolCount}, StatsPools={StatisticsPoolCount}, " + $"TotalItems={TotalPooledItems}, MaxItems={GlobalMaxPooledItems}, " + $"Utilization={BudgetUtilization:P1}, Exceeded={IsBudgetExceeded})"; } } /// /// Mutable options for configuring intelligent pool purging for a specific type. /// public sealed class PoolPurgeTypeOptions { /// /// Gets or sets whether intelligent purging is enabled for this type. /// If null, uses the global setting. /// public bool? Enabled { get; set; } /// /// Gets or sets the idle timeout in seconds. /// Items idle longer than this are eligible for purging. /// If null, uses the global default. /// public float? IdleTimeoutSeconds { get; set; } /// /// Gets or sets the minimum number of items to always retain during purge operations. /// This is the absolute floor - pools never purge below this, ever. /// If null, uses the global default. /// public int? MinRetainCount { get; set; } /// /// Gets or sets the warm retain count for active pools. /// Active pools (accessed within ) keep this many items warm /// to avoid cold-start allocations. Idle pools purge to . /// Effective floor = max(MinRetainCount, isActive ? WarmRetainCount : 0). /// If null, uses the global default. /// public int? WarmRetainCount { get; set; } /// /// Gets or sets the buffer multiplier for comfortable pool size calculation. /// If null, uses the global default. /// public float? BufferMultiplier { get; set; } /// /// Gets or sets the rolling window duration in seconds for high water mark tracking. /// If null, uses the global default. /// public float? RollingWindowSeconds { get; set; } /// /// Gets or sets the hysteresis duration in seconds. /// After a usage spike, purging is suppressed for this duration. /// If null, uses the global default. /// public float? HysteresisSeconds { get; set; } /// /// Gets or sets the spike threshold multiplier. /// A spike is detected when concurrent rentals exceed the rolling average by this factor. /// If null, uses the global default. /// public float? SpikeThresholdMultiplier { get; set; } /// /// Gets or sets the maximum number of items to purge per operation. /// Limits GC pressure by spreading large purge operations across multiple calls. /// A value of 0 means unlimited (purge all eligible items in one operation). /// If null, uses the global default. /// public int? MaxPurgesPerOperation { get; set; } /// /// Gets or sets the maximum pool size. /// Pools exceeding this limit will have items purged. /// A value of 0 means unbounded (no size limit). /// If null, uses the global default. /// public int? MaxPoolSize { get; set; } } /// /// Immutable effective configuration for intelligent pool purging. /// public readonly struct PoolPurgeEffectiveOptions { /// /// Gets whether intelligent purging is enabled. /// public bool Enabled { get; } /// /// Gets the idle timeout in seconds. /// public float IdleTimeoutSeconds { get; } /// /// Gets the minimum number of items to always retain. /// This is the absolute floor - pools never purge below this, ever. /// public int MinRetainCount { get; } /// /// Gets the warm retain count for active pools. /// Active pools keep this many items warm to avoid cold-start allocations. /// public int WarmRetainCount { get; } /// /// Gets the buffer multiplier for comfortable pool size calculation. /// public float BufferMultiplier { get; } /// /// Gets the rolling window duration in seconds for high water mark tracking. /// public float RollingWindowSeconds { get; } /// /// Gets the hysteresis duration in seconds. /// public float HysteresisSeconds { get; } /// /// Gets the spike threshold multiplier. /// public float SpikeThresholdMultiplier { get; } /// /// Gets the maximum number of items to purge per operation. /// A value of 0 means unlimited (purge all eligible items in one operation). /// public int MaxPurgesPerOperation { get; } /// /// Gets the maximum pool size. /// A value of 0 means unbounded (no size limit). /// public int MaxPoolSize { get; } /// /// Gets the source of this configuration. /// public PoolPurgeConfigurationSource Source { get; } /// /// Creates a new effective options instance. /// public PoolPurgeEffectiveOptions( bool enabled, float idleTimeoutSeconds, int minRetainCount, int warmRetainCount, float bufferMultiplier, float rollingWindowSeconds, float hysteresisSeconds, float spikeThresholdMultiplier, int maxPurgesPerOperation, int maxPoolSize, PoolPurgeConfigurationSource source ) { Enabled = enabled; IdleTimeoutSeconds = idleTimeoutSeconds; MinRetainCount = minRetainCount; WarmRetainCount = warmRetainCount; BufferMultiplier = bufferMultiplier; RollingWindowSeconds = rollingWindowSeconds; HysteresisSeconds = hysteresisSeconds; SpikeThresholdMultiplier = spikeThresholdMultiplier; MaxPurgesPerOperation = maxPurgesPerOperation; MaxPoolSize = maxPoolSize; Source = source; } /// public override string ToString() { return $"PoolPurgeEffectiveOptions(Enabled={Enabled}, IdleTimeout={IdleTimeoutSeconds}s, " + $"MinRetain={MinRetainCount}, WarmRetain={WarmRetainCount}, Buffer={BufferMultiplier}, " + $"Window={RollingWindowSeconds}s, Hysteresis={HysteresisSeconds}s, " + $"SpikeThreshold={SpikeThresholdMultiplier}, MaxPurgesPerOp={MaxPurgesPerOperation}, " + $"MaxPoolSize={MaxPoolSize}, Source={Source})"; } } /// /// Indicates the source of a pool purge configuration. /// public enum PoolPurgeConfigurationSource { /// /// Configuration comes from global defaults. /// GlobalDefaults = 0, /// /// Configuration comes from a type-specific setting. /// TypeSpecific = 1, /// /// Configuration comes from a generic type pattern. /// GenericPattern = 2, /// /// Configuration comes from a on the type. /// Attribute = 3, /// /// The type is explicitly disabled. /// TypeDisabled = 4, /// /// Configuration comes from UnityHelpersSettings per-type configuration. /// UnityHelpersSettingsPerType = 5, /// /// Configuration comes from built-in type-aware defaults. /// These are sensible defaults for common types (arrays, collections, StringBuilder) /// that are applied at the lowest priority level before global defaults. /// BuiltInDefaults = 6, } }