From 19632c0e77c836167e0644cec849df2a1e879b04 Mon Sep 17 00:00:00 2001 From: Min-Gu Yoo Date: Tue, 30 Jun 2026 12:05:41 -0700 Subject: [PATCH 01/15] =?UTF-8?q?refactor:=20rename=20auto=5Fcompact=20?= =?UTF-8?q?=E2=86=92=20auto=5Fmanage=20(unify=20the=20maintenance=20engine?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The background-maintenance engine is about to gain a second action (auto-trim), so "compact" undersells it. Rename the unreleased umbrella `auto_compact` → `auto_manage` across CPU, legacy, Metal, CUDA, and tests — const `AUTO_COMPACT`→`AUTO_MANAGE`, Preference `auto_compact`→`auto_manage`, `enable_/disable_/…_enabled`, the engine internals (`_run_/_maybe_/register_/_…_sweep!`, `_AUTO_MANAGE_{REGISTRY,TIMER,CONFIG,LOCK}`, `_AutoManageConfig`), and `src/auto_compact.jl`→`src/auto_manage.jl` (+ legacy + exts + test files). Pure rename — no behavior change. The compact *action* is untouched: `compact!`, the `_compact_requested` flag, and "compaction" wording all stay. Safe to rename: no released/registered version ships `auto_compact` (latest tag v0.3.7 predates #44/#45/#46; version still 0.3.7). Validated: renamed CPU + legacy tests green; full Metal suite green (ext rename loads); runic clean; 0 leftover `auto_compact`/`AUTO_COMPACT`/`AutoCompact` references. CUDA rename validated on the remote box. --- .../AdaptiveArrayPoolsCUDAExt.jl | 2 +- .../{auto_compact.jl => auto_manage.jl} | 10 +- .../task_local_pool.jl | 4 +- ext/AdaptiveArrayPoolsCUDAExt/types.jl | 2 +- .../AdaptiveArrayPoolsMetalExt.jl | 2 +- .../{auto_compact.jl => auto_manage.jl} | 10 +- .../task_local_pool.jl | 4 +- ext/AdaptiveArrayPoolsMetalExt/types.jl | 2 +- src/AdaptiveArrayPools.jl | 6 +- src/{auto_compact.jl => auto_manage.jl} | 144 ++++++++--------- src/legacy/auto_compact.jl | 49 ------ src/legacy/auto_manage.jl | 49 ++++++ src/macros.jl | 18 +-- src/task_local_pool.jl | 4 +- src/types.jl | 4 +- test/cuda/runtests.jl | 2 +- ...st_auto_compact.jl => test_auto_manage.jl} | 60 +++---- test/metal/runtests.jl | 2 +- ...st_auto_compact.jl => test_auto_manage.jl} | 60 +++---- test/runtests.jl | 10 +- test/test_auto_compact_legacy.jl | 32 ---- ...st_auto_compact.jl => test_auto_manage.jl} | 150 +++++++++--------- test/test_auto_manage_legacy.jl | 32 ++++ 23 files changed, 329 insertions(+), 329 deletions(-) rename ext/AdaptiveArrayPoolsCUDAExt/{auto_compact.jl => auto_manage.jl} (78%) rename ext/AdaptiveArrayPoolsMetalExt/{auto_compact.jl => auto_manage.jl} (77%) rename src/{auto_compact.jl => auto_manage.jl} (59%) delete mode 100644 src/legacy/auto_compact.jl create mode 100644 src/legacy/auto_manage.jl rename test/cuda/{test_auto_compact.jl => test_auto_manage.jl} (79%) rename test/metal/{test_auto_compact.jl => test_auto_manage.jl} (79%) delete mode 100644 test/test_auto_compact_legacy.jl rename test/{test_auto_compact.jl => test_auto_manage.jl} (69%) create mode 100644 test/test_auto_manage_legacy.jl diff --git a/ext/AdaptiveArrayPoolsCUDAExt/AdaptiveArrayPoolsCUDAExt.jl b/ext/AdaptiveArrayPoolsCUDAExt/AdaptiveArrayPoolsCUDAExt.jl index eaa305c6..766eb288 100644 --- a/ext/AdaptiveArrayPoolsCUDAExt/AdaptiveArrayPoolsCUDAExt.jl +++ b/ext/AdaptiveArrayPoolsCUDAExt/AdaptiveArrayPoolsCUDAExt.jl @@ -34,7 +34,7 @@ using CUDA include("state.jl") # Auto-compact: service the timer-flagged request at `@with_pool :cuda` entry - include("auto_compact.jl") + include("auto_manage.jl") # Safety: poisoning, escape detection, borrow tracking include("debug.jl") diff --git a/ext/AdaptiveArrayPoolsCUDAExt/auto_compact.jl b/ext/AdaptiveArrayPoolsCUDAExt/auto_manage.jl similarity index 78% rename from ext/AdaptiveArrayPoolsCUDAExt/auto_compact.jl rename to ext/AdaptiveArrayPoolsCUDAExt/auto_manage.jl index 7ddd0649..ee664b40 100644 --- a/ext/AdaptiveArrayPoolsCUDAExt/auto_compact.jl +++ b/ext/AdaptiveArrayPoolsCUDAExt/auto_manage.jl @@ -2,7 +2,7 @@ # CUDA Auto-Compact: service a flagged CUDA pool at its `@with_pool :cuda` entry. # ============================================================================== # -# Parity with the CPU `_maybe_auto_compact!(::AdaptiveArrayPool)` in src/auto_compact.jl +# Parity with the CPU `_maybe_auto_manage!(::AdaptiveArrayPool)` in src/auto_manage.jl # and the Metal mirror. The single global Timer (base module, CPU thread) sets this pool's # `@atomic _compact_requested` flag via the shared WeakRef registry; the owner task runs # the full `compact!` here, at the `_current_depth == 1` safepoint where nothing is borrowed. @@ -10,16 +10,16 @@ # The scope-entry hook itself is already emitted by the SHARED `@with_pool` codegen (it # expands `@with_pool :cuda` through the same `_generate_block_inner`), so this concrete # method is all that's needed to make the hook act on CUDA pools — before it, the hook -# resolved to the `::Any` no-op fallback. Reuses the base `_run_auto_compact!` (CUDA +# resolved to the `::Any` no-op fallback. Reuses the base `_run_auto_manage!` (CUDA # `compact!` takes the same factor/shrink_to/min_bytes/active kwargs as CPU). # # `@inline` so the common case (flag clear) is a cheap inlined monotonic read + compare; -# the cold `_run_auto_compact!` stays a non-inlined call. `:monotonic` matches CPU: the +# the cold `_run_auto_manage!` stays a non-inlined call. `:monotonic` matches CPU: the # flag is a one-way eventual-visibility signal with no cross-field ordering invariant. -@inline function AdaptiveArrayPools._maybe_auto_compact!(pool::CuAdaptiveArrayPool) +@inline function AdaptiveArrayPools._maybe_auto_manage!(pool::CuAdaptiveArrayPool) if (@atomic :monotonic pool._compact_requested) && pool._current_depth == 1 - AdaptiveArrayPools._run_auto_compact!(pool) + AdaptiveArrayPools._run_auto_manage!(pool) end return nothing end diff --git a/ext/AdaptiveArrayPoolsCUDAExt/task_local_pool.jl b/ext/AdaptiveArrayPoolsCUDAExt/task_local_pool.jl index e81b79df..3f6f0cc8 100644 --- a/ext/AdaptiveArrayPoolsCUDAExt/task_local_pool.jl +++ b/ext/AdaptiveArrayPoolsCUDAExt/task_local_pool.jl @@ -36,10 +36,10 @@ Uses `Dict{Int, CuAdaptiveArrayPool{RUNTIME_CHECK}}` in task-local storage, keye if pool === nothing pool = CuAdaptiveArrayPool() # Uses RUNTIME_CHECK for initial S pools[dev_id] = pool - # Register for auto-compaction (gated by the compile-time const; mirrors CPU + # Register for auto-manageion (gated by the compile-time const; mirrors CPU # get_task_local_pool). One entry per (task, device) pool. DCE'd when the # Preference is off, so no registry traffic on the zero-overhead build. - AdaptiveArrayPools.AUTO_COMPACT && AdaptiveArrayPools.register_auto_compact!(pool) + AdaptiveArrayPools.AUTO_MANAGE && AdaptiveArrayPools.register_auto_manage!(pool) end return pool::CuAdaptiveArrayPool{RUNTIME_CHECK} diff --git a/ext/AdaptiveArrayPoolsCUDAExt/types.jl b/ext/AdaptiveArrayPoolsCUDAExt/types.jl index e4998582..6c2e45c5 100644 --- a/ext/AdaptiveArrayPoolsCUDAExt/types.jl +++ b/ext/AdaptiveArrayPoolsCUDAExt/types.jl @@ -151,7 +151,7 @@ function CuAdaptiveArrayPool{S}() where {S} "", # _pending_callsite "", # _pending_return_site nothing, # _borrow_log: lazily created when S >= 1 - false # _compact_requested: no pending auto-compact request + false # _compact_requested: no pending auto-manage request ) end diff --git a/ext/AdaptiveArrayPoolsMetalExt/AdaptiveArrayPoolsMetalExt.jl b/ext/AdaptiveArrayPoolsMetalExt/AdaptiveArrayPoolsMetalExt.jl index db1211a7..00b43251 100644 --- a/ext/AdaptiveArrayPoolsMetalExt/AdaptiveArrayPoolsMetalExt.jl +++ b/ext/AdaptiveArrayPoolsMetalExt/AdaptiveArrayPoolsMetalExt.jl @@ -27,7 +27,7 @@ using Metal include("acquire.jl") include("task_local_pool.jl") include("state.jl") - include("auto_compact.jl") + include("auto_manage.jl") include("debug.jl") include("utils.jl") include("macros.jl") diff --git a/ext/AdaptiveArrayPoolsMetalExt/auto_compact.jl b/ext/AdaptiveArrayPoolsMetalExt/auto_manage.jl similarity index 77% rename from ext/AdaptiveArrayPoolsMetalExt/auto_compact.jl rename to ext/AdaptiveArrayPoolsMetalExt/auto_manage.jl index 8c9b9d18..0c761fc4 100644 --- a/ext/AdaptiveArrayPoolsMetalExt/auto_compact.jl +++ b/ext/AdaptiveArrayPoolsMetalExt/auto_manage.jl @@ -2,7 +2,7 @@ # Metal Auto-Compact: service a flagged Metal pool at its `@with_pool :metal` entry. # ============================================================================== # -# Parity with the CPU `_maybe_auto_compact!(::AdaptiveArrayPool)` in src/auto_compact.jl. +# Parity with the CPU `_maybe_auto_manage!(::AdaptiveArrayPool)` in src/auto_manage.jl. # The single global Timer (base module, CPU thread) sets this pool's `@atomic # _compact_requested` flag via the shared WeakRef registry; the owner task runs the full # `compact!` here, at the `_current_depth == 1` safepoint where nothing is borrowed. @@ -10,16 +10,16 @@ # The scope-entry hook itself is already emitted by the SHARED `@with_pool` codegen # (it expands `@with_pool :metal` through the same `_generate_block_inner`), so this # concrete method is all that's needed to make the hook act on Metal pools — before it, -# the hook resolved to the `::Any` no-op fallback. Reuses the base `_run_auto_compact!` +# the hook resolved to the `::Any` no-op fallback. Reuses the base `_run_auto_manage!` # (Metal `compact!` takes the same factor/shrink_to/min_bytes/active kwargs as CPU). # # `@inline` so the common case (flag clear) is a cheap inlined monotonic read + compare; -# the cold `_run_auto_compact!` stays a non-inlined call. `:monotonic` matches CPU: the +# the cold `_run_auto_manage!` stays a non-inlined call. `:monotonic` matches CPU: the # flag is a one-way eventual-visibility signal with no cross-field ordering invariant. -@inline function AdaptiveArrayPools._maybe_auto_compact!(pool::MetalAdaptiveArrayPool) +@inline function AdaptiveArrayPools._maybe_auto_manage!(pool::MetalAdaptiveArrayPool) if (@atomic :monotonic pool._compact_requested) && pool._current_depth == 1 - AdaptiveArrayPools._run_auto_compact!(pool) + AdaptiveArrayPools._run_auto_manage!(pool) end return nothing end diff --git a/ext/AdaptiveArrayPoolsMetalExt/task_local_pool.jl b/ext/AdaptiveArrayPoolsMetalExt/task_local_pool.jl index f77984f0..457de3bb 100644 --- a/ext/AdaptiveArrayPoolsMetalExt/task_local_pool.jl +++ b/ext/AdaptiveArrayPoolsMetalExt/task_local_pool.jl @@ -37,10 +37,10 @@ Uses `Dict{UInt64, MetalAdaptiveArrayPool{RUNTIME_CHECK, METAL_STORAGE}}` in tas if pool === nothing pool = MetalAdaptiveArrayPool() # Uses RUNTIME_CHECK for initial R pools[dev_key] = pool - # Register for auto-compaction (gated by the compile-time const; mirrors CPU + # Register for auto-manageion (gated by the compile-time const; mirrors CPU # get_task_local_pool). One entry per (task, device) pool. DCE'd when the # Preference is off, so no registry traffic on the zero-overhead build. - AdaptiveArrayPools.AUTO_COMPACT && AdaptiveArrayPools.register_auto_compact!(pool) + AdaptiveArrayPools.AUTO_MANAGE && AdaptiveArrayPools.register_auto_manage!(pool) end return pool::MetalAdaptiveArrayPool{RUNTIME_CHECK, METAL_STORAGE} diff --git a/ext/AdaptiveArrayPoolsMetalExt/types.jl b/ext/AdaptiveArrayPoolsMetalExt/types.jl index bab1d661..f756becf 100644 --- a/ext/AdaptiveArrayPoolsMetalExt/types.jl +++ b/ext/AdaptiveArrayPoolsMetalExt/types.jl @@ -144,7 +144,7 @@ function MetalAdaptiveArrayPool{R, S}() where {R, S} "", # _pending_callsite "", # _pending_return_site nothing, # _borrow_log: lazily created when R >= 1 - false, # _compact_requested: no pending auto-compact request + false, # _compact_requested: no pending auto-manage request ) end diff --git a/src/AdaptiveArrayPools.jl b/src/AdaptiveArrayPools.jl index 19349695..5a36d13d 100644 --- a/src/AdaptiveArrayPools.jl +++ b/src/AdaptiveArrayPools.jl @@ -17,7 +17,7 @@ export @with_pool, @maybe_with_pool, @safe_with_pool, @safe_maybe_with_pool export STATIC_POOLING, MAYBE_POOLING, RUNTIME_CHECK export PoolEscapeError, EscapePoint, PoolMutationError, MutationPoint export checkpoint!, rewind!, reset!, trim!, compact! -export enable_auto_compact!, disable_auto_compact!, auto_compact_enabled, AUTO_COMPACT +export enable_auto_manage!, disable_auto_manage!, auto_manage_enabled, AUTO_MANAGE export get_task_local_cuda_pool, get_task_local_cuda_pools # CUDA (stubs, overridden by extension) export get_task_local_metal_pool, get_task_local_metal_pools # Metal (stubs, overridden by extension) @@ -34,7 +34,7 @@ export DisabledPool, DISABLED_CPU, pooling_enabled # Disabled pool support include("bitarray.jl") include("convenience.jl") include("state.jl") - include("auto_compact.jl") + include("auto_manage.jl") include("task_local_pool.jl") include("debug.jl") include("macros.jl") @@ -46,7 +46,7 @@ else include("legacy/bitarray.jl") include("convenience.jl") include("legacy/state.jl") - include("legacy/auto_compact.jl") + include("legacy/auto_manage.jl") include("task_local_pool.jl") include("debug.jl") include("macros.jl") diff --git a/src/auto_compact.jl b/src/auto_manage.jl similarity index 59% rename from src/auto_compact.jl rename to src/auto_manage.jl index 25da8e8c..c2c08bec 100644 --- a/src/auto_compact.jl +++ b/src/auto_manage.jl @@ -1,24 +1,24 @@ # ============================================================================== # Auto-Compact: timer-driven background capacity compaction. -# Design: docs/plans/DESIGN_auto_compact.md +# Design: docs/plans/DESIGN_auto_manage.md # # A single global Timer periodically *requests* compaction by setting each live # pool's `@atomic _compact_requested` flag (via a WeakRef registry). Each pool's # owner task *executes* the request — the full `compact!` — at its next `@with_pool` # ENTRY (the `_current_depth == 1` safepoint), where nothing is borrowed. # -# Tier 1 (compile-time): `AUTO_COMPACT` gates whether the scope-entry flag check is +# Tier 1 (compile-time): `AUTO_MANAGE` gates whether the scope-entry flag check is # emitted (macros.jl) and whether pools register (task_local_pool.jl). Default ON # (~0.6 ns/entry); set the Preference false to compile it OUT (full DCE → zero cost). -# Tier 2 (runtime): `enable_auto_compact!` / `disable_auto_compact!`. +# Tier 2 (runtime): `enable_auto_manage!` / `disable_auto_manage!`. # ============================================================================== using Preferences: @load_preference """ - AUTO_COMPACT::Bool + AUTO_MANAGE::Bool -Compile-time master switch (Tier 1) for auto-compaction. Default `true`: the scope-entry +Compile-time master switch (Tier 1) for auto-manageion. Default `true`: the scope-entry flag check is emitted (a ~0.6 ns inlined check per `@with_pool` entry), new task-local pools auto-register, and `__init__` auto-starts the background Timer. Set `false` in `LocalPreferences.toml` (then restart) to compile the feature OUT entirely — the hook is @@ -26,34 +26,34 @@ dead-code-eliminated, restoring zero hot-path cost: ```toml [AdaptiveArrayPools] -auto_compact = false +auto_manage = false ``` -`enable_auto_compact!` / `disable_auto_compact!` control the background Timer at runtime +`enable_auto_manage!` / `disable_auto_manage!` control the background Timer at runtime (Tier 2). """ -const AUTO_COMPACT = @load_preference("auto_compact", true)::Bool +const AUTO_MANAGE = @load_preference("auto_manage", true)::Bool -# Registry of pools eligible for auto-compaction. `WeakRef` so a pool dies with its +# Registry of pools eligible for auto-manageion. `WeakRef` so a pool dies with its # task; dead refs are swap-removed on each sweep. All access under the lock below. -const _AUTO_COMPACT_REGISTRY = WeakRef[] -const _AUTO_COMPACT_LOCK = ReentrantLock() +const _AUTO_MANAGE_REGISTRY = WeakRef[] +const _AUTO_MANAGE_LOCK = ReentrantLock() -# Mutable global config, set by `enable_auto_compact!`, read by `_run_auto_compact!`. +# Mutable global config, set by `enable_auto_manage!`, read by `_run_auto_manage!`. # Mirrors the `compact!` knobs; `factor`/`shrink_to` are `Float64` for stable storage. -mutable struct _AutoCompactConfig +mutable struct _AutoManageConfig factor::Float64 shrink_to::Float64 min_bytes::Int active::Bool end -const _AUTO_COMPACT_CONFIG = _AutoCompactConfig(10.0, 1.5, 2^20, true) +const _AUTO_MANAGE_CONFIG = _AutoManageConfig(10.0, 1.5, 2^20, true) """ - register_auto_compact!(pool) + register_auto_manage!(pool) -Add `pool` to the auto-compact registry (under lock) so the background Timer's sweep -will flag it. Called from the task-local-pool slow path when `AUTO_COMPACT` is on +Add `pool` to the auto-manage registry (under lock) so the background Timer's sweep +will flag it. Called from the task-local-pool slow path when `AUTO_MANAGE` is on (one entry per task-local pool). The same registry serves every backend (CPU `AdaptiveArrayPool`, GPU `MetalAdaptiveArrayPool`/`CuAdaptiveArrayPool`); each carries its own `@atomic _compact_requested` flag and services it at its own scope entry. @@ -62,24 +62,24 @@ A pool *without* that flag (e.g. `DisabledPool`, or a foreign `AbstractArrayPool subtype) is silently skipped: registering one would make the sweep throw on its missing `_compact_requested` and abort the cycle, starving every pool after it in the registry. """ -function register_auto_compact!(pool::AbstractArrayPool) +function register_auto_manage!(pool::AbstractArrayPool) # Only pools carrying the cross-thread flag can be swept (const-folds per concrete type). hasfield(typeof(pool), :_compact_requested) || return nothing - lock(() -> push!(_AUTO_COMPACT_REGISTRY, WeakRef(pool)), _AUTO_COMPACT_LOCK) + lock(() -> push!(_AUTO_MANAGE_REGISTRY, WeakRef(pool)), _AUTO_MANAGE_LOCK) return nothing end # The Timer callback body: set every live pool's request flag and prune dead refs. # Takes the timer (unused) so it can be handed straight to `Timer(cb, …)`. The caller # wraps it in try/catch (Phase 2) — a throwing callback silently kills a Julia Timer. -function _auto_compact_sweep!(_timer) - lock(_AUTO_COMPACT_LOCK) do +function _auto_manage_sweep!(_timer) + lock(_AUTO_MANAGE_LOCK) do i = 1 - while i <= length(_AUTO_COMPACT_REGISTRY) - pool = _AUTO_COMPACT_REGISTRY[i].value + while i <= length(_AUTO_MANAGE_REGISTRY) + pool = _AUTO_MANAGE_REGISTRY[i].value if pool === nothing - _AUTO_COMPACT_REGISTRY[i] = _AUTO_COMPACT_REGISTRY[end] - pop!(_AUTO_COMPACT_REGISTRY) # dead → swap-remove + _AUTO_MANAGE_REGISTRY[i] = _AUTO_MANAGE_REGISTRY[end] + pop!(_AUTO_MANAGE_REGISTRY) # dead → swap-remove else p = pool::AbstractArrayPool # CPU or any GPU backend pool @atomic :monotonic p._compact_requested = true # request compaction @@ -92,92 +92,92 @@ end # Owner-side execution: reset the flag BEFORE the work (so a concurrent sweep's re-request # is not lost), snapshot the config under the lock (it may be rewritten from another thread -# by `enable_auto_compact!`), then run the full `compact!`. Wrapped in try/catch so a +# by `enable_auto_manage!`), then run the full `compact!`. Wrapped in try/catch so a # background-maintenance failure (e.g. OOM allocating the new backing) is logged and the # cycle skipped — it must never surface as a user-visible error at the `@with_pool` boundary. # Returns nothing; the summary is discarded (no allocation). Shared across backends: # `compact!` takes the same factor/shrink_to/min_bytes/active kwargs for every # `AbstractArrayPool`, so one implementation drives CPU and GPU pools alike. -function _run_auto_compact!(pool::AbstractArrayPool) +function _run_auto_manage!(pool::AbstractArrayPool) @atomic :monotonic pool._compact_requested = false - factor, shrink_to, min_bytes, active = lock(_AUTO_COMPACT_LOCK) do - cfg = _AUTO_COMPACT_CONFIG + factor, shrink_to, min_bytes, active = lock(_AUTO_MANAGE_LOCK) do + cfg = _AUTO_MANAGE_CONFIG (cfg.factor, cfg.shrink_to, cfg.min_bytes, cfg.active) end try compact!(pool; factor, shrink_to, min_bytes, active) catch e - @warn "auto-compact failed; skipping this cycle" exception = (e, catch_backtrace()) maxlog = 3 + @warn "auto-manage failed; skipping this cycle" exception = (e, catch_backtrace()) maxlog = 3 end return nothing end # Scope-ENTRY hook body, generated before the checkpoint in every `@with_pool` (gated by -# AUTO_COMPACT). At the `_current_depth == 1` safepoint (entering the outermost scope from +# AUTO_MANAGE). At the `_current_depth == 1` safepoint (entering the outermost scope from # global), run a flagged pool's compaction. Servicing the request at entry handles EVERY # exit type of the previous scope — normal / early return / break / continue / exception — # with a single hook point, and never runs compaction inside a `finally` during unwind. # `@inline` so the common case (flag clear) is a cheap inlined monotonic read + compare; the -# cold `_run_auto_compact!` stays a non-inlined call. The `::Any` fallback keeps the SHARED +# cold `_run_auto_manage!` stays a non-inlined call. The `::Any` fallback keeps the SHARED # `@with_pool` codegen safe for pools that don't opt in (e.g. `DisabledPool`, or a backend # without its own method) — they no-op here. GPU backends define their own concrete method -# in their extension (e.g. `_maybe_auto_compact!(::MetalAdaptiveArrayPool)`), reusing the -# shared `_run_auto_compact!`. `:monotonic` suffices: the flag is a one-way eventual-visibility +# in their extension (e.g. `_maybe_auto_manage!(::MetalAdaptiveArrayPool)`), reusing the +# shared `_run_auto_manage!`. `:monotonic` suffices: the flag is a one-way eventual-visibility # signal with no cross-field ordering invariant (cheaper than seq-cst on weak-ordered CPUs). -@inline _maybe_auto_compact!(::Any) = nothing -@inline function _maybe_auto_compact!(pool::AdaptiveArrayPool) +@inline _maybe_auto_manage!(::Any) = nothing +@inline function _maybe_auto_manage!(pool::AdaptiveArrayPool) if (@atomic :monotonic pool._compact_requested) && pool._current_depth == 1 - _run_auto_compact!(pool) + _run_auto_manage!(pool) end return nothing end # ── Timer lifecycle (Tier 2 runtime control) ───────────────────────────────── -# Handle to the single global auto-compact Timer (`nothing` = stopped). -const _AUTO_COMPACT_TIMER = Ref{Union{Nothing, Timer}}(nothing) +# Handle to the single global auto-manage Timer (`nothing` = stopped). +const _AUTO_MANAGE_TIMER = Ref{Union{Nothing, Timer}}(nothing) # Timer callback: the sweep wrapped so a throw can't silently kill the Timer (an # uncaught error in a Julia Timer callback stops it permanently). -function _safe_auto_compact_sweep!(timer) +function _safe_auto_manage_sweep!(timer) try - _auto_compact_sweep!(timer) + _auto_manage_sweep!(timer) catch e - @warn "auto-compact sweep failed (timer continues)" exception = (e, catch_backtrace()) maxlog = 3 + @warn "auto-manage sweep failed (timer continues)" exception = (e, catch_backtrace()) maxlog = 3 end return nothing end # Lock-guarded timer lifecycle: the firing callback and concurrent enable!/disable! must not -# interleave a read-modify-write on `_AUTO_COMPACT_TIMER` (→ a leaked or double-closed Timer). -function _start_auto_compact_timer!(interval::Real) - lock(_AUTO_COMPACT_LOCK) do - _stop_auto_compact_timer_unlocked!() # replace any existing - _AUTO_COMPACT_TIMER[] = Timer(_safe_auto_compact_sweep!, interval; interval = interval) +# interleave a read-modify-write on `_AUTO_MANAGE_TIMER` (→ a leaked or double-closed Timer). +function _start_auto_manage_timer!(interval::Real) + lock(_AUTO_MANAGE_LOCK) do + _stop_auto_manage_timer_unlocked!() # replace any existing + _AUTO_MANAGE_TIMER[] = Timer(_safe_auto_manage_sweep!, interval; interval = interval) end return nothing end -_stop_auto_compact_timer!() = (lock(_stop_auto_compact_timer_unlocked!, _AUTO_COMPACT_LOCK); nothing) +_stop_auto_manage_timer!() = (lock(_stop_auto_manage_timer_unlocked!, _AUTO_MANAGE_LOCK); nothing) -function _stop_auto_compact_timer_unlocked!() - t = _AUTO_COMPACT_TIMER[] +function _stop_auto_manage_timer_unlocked!() + t = _AUTO_MANAGE_TIMER[] if t !== nothing close(t) - _AUTO_COMPACT_TIMER[] = nothing + _AUTO_MANAGE_TIMER[] = nothing end return nothing end """ - enable_auto_compact!(; interval = 30.0, factor = 10, shrink_to = 1.5, + enable_auto_manage!(; interval = 30.0, factor = 10, shrink_to = 1.5, min_bytes = 2^20, active = true) -Start (or restart) the background auto-compact `Timer` with the given config. Every +Start (or restart) the background auto-manage `Timer` with the given config. Every `interval` seconds it flags every registered pool; each pool then runs the full `compact!` at its next `@with_pool` **entry** (the `_current_depth == 1` safepoint). -Requires the `auto_compact` compile-time Preference (`AUTO_COMPACT`) for the scope-entry +Requires the `auto_manage` compile-time Preference (`AUTO_MANAGE`) for the scope-entry hook and pool auto-registration. With the Preference **off**, the `@with_pool` hook is compiled out, so even though this warns-then-starts the timer (and the timer keeps setting flags), nothing ever *services* those flags — no automatic compaction happens, and @@ -187,55 +187,55 @@ registering pools manually does not change that. Set the Preference and restart, !!! note "What gets compacted, when, and the zero-alloc trade-off" Auto-compaction targets the **task-local pool** (`get_task_local_pool()`) — the pool `@with_pool` uses and the only one that auto-registers. A hand-made `AdaptiveArrayPool()` - is **not** auto-registered (so not auto-compacted); `register_auto_compact!` it explicitly + is **not** auto-registered (so not auto-manageed); `register_auto_manage!` it explicitly to include it in the sweep, or just call `compact!` on it directly. The work runs at a `@with_pool` **entry** (`_current_depth == 1`), so an idle task with no `@with_pool` activity won't compact until it next enters a scope. Because a compaction allocates the new right-sized backing(s), a `@with_pool` that services a pending request - is **not** strictly zero-allocation; set `auto_compact = false` (Preference) for a + is **not** strictly zero-allocation; set `auto_manage = false` (Preference) for a guaranteed-zero-alloc hot path. -See also [`disable_auto_compact!`](@ref), [`auto_compact_enabled`](@ref). +See also [`disable_auto_manage!`](@ref), [`auto_manage_enabled`](@ref). """ -function enable_auto_compact!(; +function enable_auto_manage!(; interval::Real = 30.0, factor::Real = 10, shrink_to::Real = 1.5, min_bytes::Int = 2^20, active::Bool = true, ) - AUTO_COMPACT || @warn "enable_auto_compact!: the `auto_compact` Preference is off — the `@with_pool` hook is compiled out, so the timer's flags are never serviced and no automatic compaction happens (registering pools does not change this). Set the Preference and restart to activate, or call `compact!` manually." maxlog = 1 - lock(_AUTO_COMPACT_LOCK) do - cfg = _AUTO_COMPACT_CONFIG + AUTO_MANAGE || @warn "enable_auto_manage!: the `auto_manage` Preference is off — the `@with_pool` hook is compiled out, so the timer's flags are never serviced and no automatic compaction happens (registering pools does not change this). Set the Preference and restart to activate, or call `compact!` manually." maxlog = 1 + lock(_AUTO_MANAGE_LOCK) do + cfg = _AUTO_MANAGE_CONFIG cfg.factor = Float64(factor) cfg.shrink_to = Float64(shrink_to) cfg.min_bytes = min_bytes cfg.active = active end - _start_auto_compact_timer!(interval) + _start_auto_manage_timer!(interval) return nothing end """ - disable_auto_compact!() + disable_auto_manage!() -Stop the background auto-compact `Timer`. Idempotent. +Stop the background auto-manage `Timer`. Idempotent. """ -disable_auto_compact!() = _stop_auto_compact_timer!() +disable_auto_manage!() = _stop_auto_manage_timer!() """ - auto_compact_enabled() -> Bool + auto_manage_enabled() -> Bool -Whether the background auto-compact `Timer` is currently running. +Whether the background auto-manage `Timer` is currently running. """ -auto_compact_enabled() = _AUTO_COMPACT_TIMER[] !== nothing +auto_manage_enabled() = _AUTO_MANAGE_TIMER[] !== nothing -# Auto-start the background timer when AUTO_COMPACT is on — but NOT while generating +# Auto-start the background timer when AUTO_MANAGE is on — but NOT while generating # precompile output. A live Timer is an open libuv handle, so a precompile worker (e.g. # precompiling a CUDA/Metal extension, which loads this package and runs `__init__`) # would hang with "unfinished IO handles with timer events". `jl_generating_output()` is # nonzero exactly during precompile/sysimage output. Also close the timer at process exit. function __init__() - if AUTO_COMPACT && ccall(:jl_generating_output, Cint, ()) == 0 - enable_auto_compact!() - atexit(disable_auto_compact!) + if AUTO_MANAGE && ccall(:jl_generating_output, Cint, ()) == 0 + enable_auto_manage!() + atexit(disable_auto_manage!) end return nothing end diff --git a/src/legacy/auto_compact.jl b/src/legacy/auto_compact.jl deleted file mode 100644 index ea8e02f0..00000000 --- a/src/legacy/auto_compact.jl +++ /dev/null @@ -1,49 +0,0 @@ -# ============================================================================== -# Auto-Compact — legacy (Julia < 1.12) no-op shims. -# -# The modern src/auto_compact.jl is NOT included on the legacy path, but the SHARED -# macros.jl and task_local_pool.jl reference AUTO_COMPACT / _maybe_auto_compact! / -# register_auto_compact!. Define them here as no-ops so the shared code compiles and the -# generated @with_pool scope-entry hook DCEs away (AUTO_COMPACT === false). The public -# enable/disable/enabled API stays callable across the whole supported Julia range. -# Upgrade to Julia 1.12+ for actual background auto-compaction. -# ============================================================================== - -# Compile-time OFF: constant-folds the shared @with_pool scope-entry hook to nothing. -const AUTO_COMPACT = false - -# Referenced (and DCE'd) by get_task_local_pool's `AUTO_COMPACT && register_auto_compact!`. -register_auto_compact!(pool) = nothing - -# Referenced (and DCE'd) by the @with_pool scope-entry hook. -@inline _maybe_auto_compact!(::Any) = nothing - -""" - enable_auto_compact!(; interval = 30.0, factor = 10, shrink_to = 1.5, - min_bytes = 2^20, active = true) - -No-op on Julia < 1.12 (the legacy pool architecture has no capacity compaction). Warns -once. Upgrade to Julia 1.12+ for background auto-compaction. -""" -function enable_auto_compact!(; - interval::Real = 30.0, factor::Real = 10, shrink_to::Real = 1.5, - min_bytes::Int = 2^20, active::Bool = true, - ) - @warn "enable_auto_compact! is a no-op on Julia < 1.12 (legacy pool architecture). " * - "Upgrade to Julia 1.12+ for background auto-compaction." maxlog = 1 - return nothing -end - -""" - disable_auto_compact!() - -No-op on Julia < 1.12. See [`enable_auto_compact!`](@ref). -""" -disable_auto_compact!() = nothing - -""" - auto_compact_enabled() -> Bool - -Always `false` on Julia < 1.12 (no auto-compaction). See [`enable_auto_compact!`](@ref). -""" -auto_compact_enabled() = false diff --git a/src/legacy/auto_manage.jl b/src/legacy/auto_manage.jl new file mode 100644 index 00000000..0745d3b6 --- /dev/null +++ b/src/legacy/auto_manage.jl @@ -0,0 +1,49 @@ +# ============================================================================== +# Auto-Compact — legacy (Julia < 1.12) no-op shims. +# +# The modern src/auto_manage.jl is NOT included on the legacy path, but the SHARED +# macros.jl and task_local_pool.jl reference AUTO_MANAGE / _maybe_auto_manage! / +# register_auto_manage!. Define them here as no-ops so the shared code compiles and the +# generated @with_pool scope-entry hook DCEs away (AUTO_MANAGE === false). The public +# enable/disable/enabled API stays callable across the whole supported Julia range. +# Upgrade to Julia 1.12+ for actual background auto-manageion. +# ============================================================================== + +# Compile-time OFF: constant-folds the shared @with_pool scope-entry hook to nothing. +const AUTO_MANAGE = false + +# Referenced (and DCE'd) by get_task_local_pool's `AUTO_MANAGE && register_auto_manage!`. +register_auto_manage!(pool) = nothing + +# Referenced (and DCE'd) by the @with_pool scope-entry hook. +@inline _maybe_auto_manage!(::Any) = nothing + +""" + enable_auto_manage!(; interval = 30.0, factor = 10, shrink_to = 1.5, + min_bytes = 2^20, active = true) + +No-op on Julia < 1.12 (the legacy pool architecture has no capacity compaction). Warns +once. Upgrade to Julia 1.12+ for background auto-manageion. +""" +function enable_auto_manage!(; + interval::Real = 30.0, factor::Real = 10, shrink_to::Real = 1.5, + min_bytes::Int = 2^20, active::Bool = true, + ) + @warn "enable_auto_manage! is a no-op on Julia < 1.12 (legacy pool architecture). " * + "Upgrade to Julia 1.12+ for background auto-manageion." maxlog = 1 + return nothing +end + +""" + disable_auto_manage!() + +No-op on Julia < 1.12. See [`enable_auto_manage!`](@ref). +""" +disable_auto_manage!() = nothing + +""" + auto_manage_enabled() -> Bool + +Always `false` on Julia < 1.12 (no auto-manageion). See [`enable_auto_manage!`](@ref). +""" +auto_manage_enabled() = false diff --git a/src/macros.jl b/src/macros.jl index 9a49e836..2b4b6cb3 100644 --- a/src/macros.jl +++ b/src/macros.jl @@ -743,7 +743,7 @@ function _generate_block_inner(pool_name, expr, safe::Bool, source) if safe transformed_expr = _transform_return_stmts(transformed_expr, pool_name) return quote - $(_auto_compact_hook(pool_name)) + $(_auto_manage_hook(pool_name)) $checkpoint_call try local _result = $(esc(transformed_expr)) @@ -769,7 +769,7 @@ function _generate_block_inner(pool_name, expr, safe::Bool, source) return quote local $(esc(entry_depth_var)) = $(esc(pool_name))._current_depth - $(_auto_compact_hook(pool_name)) + $(_auto_manage_hook(pool_name)) $checkpoint_call local _result = $(esc(transformed_expr)) # Leaked scope cleanup BEFORE validation: if an inner @with_pool threw @@ -823,7 +823,7 @@ function _generate_function_inner(pool_name, expr, safe::Bool, source) if safe transformed_expr = _transform_return_stmts(transformed_expr, pool_name) return quote - $(_auto_compact_hook(pool_name)) + $(_auto_manage_hook(pool_name)) $checkpoint_call try local _result = $(esc(transformed_expr)) @@ -849,7 +849,7 @@ function _generate_function_inner(pool_name, expr, safe::Bool, source) return quote local $(esc(entry_depth_var)) = $(esc(pool_name))._current_depth - $(_auto_compact_hook(pool_name)) + $(_auto_manage_hook(pool_name)) $checkpoint_call local _result = $(esc(transformed_expr)) # Leaked scope cleanup BEFORE validation: if an inner @with_pool threw @@ -1593,14 +1593,14 @@ const _REWIND_REF = GlobalRef(@__MODULE__, :rewind!) const _LAZY_REWIND_REF = GlobalRef(@__MODULE__, :_lazy_rewind!) # Auto-compact scope-ENTRY hook, generated before the checkpoint in every `@with_pool`. -# Gated by the `AUTO_COMPACT` compile-time const (constant-folded → DCE'd to nothing when -# off → zero cost). Dispatches through `_maybe_auto_compact!` so non-CPU (GPU) pools safely +# Gated by the `AUTO_MANAGE` compile-time const (constant-folded → DCE'd to nothing when +# off → zero cost). Dispatches through `_maybe_auto_manage!` so non-CPU (GPU) pools safely # no-op. Placed BEFORE the checkpoint, so `_current_depth == 1` ⇒ the OUTERMOST scope is # being entered from global (nothing borrowed → safe). Servicing the flag at entry handles # every exit type of the previous scope (return/break/throw), with no compaction in `finally`. -const _AUTO_COMPACT_REF = GlobalRef(@__MODULE__, :AUTO_COMPACT) -const _MAYBE_AUTO_COMPACT_REF = GlobalRef(@__MODULE__, :_maybe_auto_compact!) -_auto_compact_hook(pool_name) = :($_AUTO_COMPACT_REF && $_MAYBE_AUTO_COMPACT_REF($(esc(pool_name)))) +const _AUTO_MANAGE_REF = GlobalRef(@__MODULE__, :AUTO_MANAGE) +const _MAYBE_AUTO_MANAGE_REF = GlobalRef(@__MODULE__, :_maybe_auto_manage!) +_auto_manage_hook(pool_name) = :($_AUTO_MANAGE_REF && $_MAYBE_AUTO_MANAGE_REF($(esc(pool_name)))) const _TYPED_LAZY_REWIND_REF = GlobalRef(@__MODULE__, :_typed_lazy_rewind!) const _CAN_USE_TYPED_PATH_REF = GlobalRef(@__MODULE__, :_can_use_typed_path) const _TRACKED_MASK_REF = GlobalRef(@__MODULE__, :_tracked_mask_for_types) diff --git a/src/task_local_pool.jl b/src/task_local_pool.jl index 475f8e86..61f79c42 100644 --- a/src/task_local_pool.jl +++ b/src/task_local_pool.jl @@ -96,8 +96,8 @@ type-asserts directly to `AdaptiveArrayPool{RUNTIME_CHECK}`. # This branch is rarely taken (only once per Task) pool = AdaptiveArrayPool() task_local_storage(_POOL_KEY, pool) - # Register for background auto-compaction (DCE'd to nothing when AUTO_COMPACT is off). - AUTO_COMPACT && register_auto_compact!(pool) + # Register for background auto-manageion (DCE'd to nothing when AUTO_MANAGE is off). + AUTO_MANAGE && register_auto_manage!(pool) end return pool::AdaptiveArrayPool{RUNTIME_CHECK} diff --git a/src/types.jl b/src/types.jl index 142a9e72..0217f731 100644 --- a/src/types.jl +++ b/src/types.jl @@ -386,7 +386,7 @@ mutable struct AdaptiveArrayPool{S} <: AbstractArrayPool # Auto-compact request flag: set by the background Timer sweep (a different # thread), read + reset by the owner task at the `_current_depth == 1` safepoint. - # Atomic for the cross-thread handoff; ignored when auto-compact is disabled. + # Atomic for the cross-thread handoff; ignored when auto-manage is disabled. @atomic _compact_requested::Bool end @@ -410,7 +410,7 @@ function AdaptiveArrayPool{S}() where {S} "", # _pending_callsite: no pending "", # _pending_return_site: no pending nothing, # _borrow_log: lazily created at S=1 - false, # _compact_requested: no pending auto-compact request + false, # _compact_requested: no pending auto-manage request ) end diff --git a/test/cuda/runtests.jl b/test/cuda/runtests.jl index a7d997fd..9052d9b5 100644 --- a/test/cuda/runtests.jl +++ b/test/cuda/runtests.jl @@ -51,6 +51,6 @@ else include("test_scope_depth_validation.jl") include("test_trim.jl") include("test_compact.jl") - include("test_auto_compact.jl") + include("test_auto_manage.jl") end end diff --git a/test/cuda/test_auto_compact.jl b/test/cuda/test_auto_manage.jl similarity index 79% rename from test/cuda/test_auto_compact.jl rename to test/cuda/test_auto_manage.jl index 84d48dd7..950868ac 100644 --- a/test/cuda/test_auto_compact.jl +++ b/test/cuda/test_auto_manage.jl @@ -1,6 +1,6 @@ # CUDA Auto-Compact Tests # ======================== -# Parity with the CPU suite (test/test_auto_compact.jl) and the Metal mirror, for CUDA. +# Parity with the CPU suite (test/test_auto_manage.jl) and the Metal mirror, for CUDA. # # The single global Timer lives in the BASE module and runs on a CPU thread. It sweeps # the shared `Vector{WeakRef}` registry and sets each live pool's `@atomic _compact_requested` @@ -9,23 +9,23 @@ # pool at `@with_pool :cuda`. The two are independent — one sweep flags both, and each # services (and clears) only its own flag at its own boundary. # -# AUTO_COMPACT defaults on, so `__init__` auto-starts the timer; we stop it up front so each +# AUTO_MANAGE defaults on, so `__init__` auto-starts the timer; we stop it up front so each # testset is deterministic. Uses Float32 (GPU's primary type). import AdaptiveArrayPools as AAP -AAP.disable_auto_compact!() # stop the __init__-started timer for deterministic tests +AAP.disable_auto_manage!() # stop the __init__-started timer for deterministic tests -@testset "CUDA auto_compact" begin +@testset "CUDA auto_manage" begin # Helpers are scoped INSIDE this testset (a local scope) so they don't define top-level # methods in `Main` that collide with the identically-named helpers in the CPU/Metal - # auto-compact test files when the full suite loads several backends' files together + # auto-manage test files when the full suite loads several backends' files together # (otherwise: "WARNING: Method definition _clear_registry!() ... overwritten"). _ccap(v) = Int(getfield(v, :maxsize) ÷ sizeof(eltype(v))) # CUDA device capacity (elements) _cpucap(v) = length(getfield(v, :ref).mem) # CPU backing capacity (coexistence test) - _clear_registry!() = lock(() -> empty!(AAP._AUTO_COMPACT_REGISTRY), AAP._AUTO_COMPACT_LOCK) - _registry_len() = lock(() -> length(AAP._AUTO_COMPACT_REGISTRY), AAP._AUTO_COMPACT_LOCK) + _clear_registry!() = lock(() -> empty!(AAP._AUTO_MANAGE_REGISTRY), AAP._AUTO_MANAGE_LOCK) + _registry_len() = lock(() -> length(AAP._AUTO_MANAGE_REGISTRY), AAP._AUTO_MANAGE_LOCK) # An inactive, bloated Float32 CUDA slot (cap = `big` high-water, last extent = `small`). function _cuda_inactive_bloated(big::Int, small::Int) pool = CuAdaptiveArrayPool{0}() @@ -45,42 +45,42 @@ AAP.disable_auto_compact!() # stop the __init__-started timer for deterministi @testset "registry sweep flags a registered CUDA pool" begin _clear_registry!() p = CuAdaptiveArrayPool{0}() - AAP.register_auto_compact!(p) + AAP.register_auto_manage!(p) @test _registry_len() == 1 - AAP._auto_compact_sweep!(nothing) # the timer callback body + AAP._auto_manage_sweep!(nothing) # the timer callback body @test (@atomic p._compact_requested) == true _clear_registry!() end - @testset "_run_auto_compact! resets the flag and compacts inactive CUDA bloat" begin + @testset "_run_auto_manage! resets the flag and compacts inactive CUDA bloat" begin pool = _cuda_inactive_bloated(1_000_000, 100) @atomic pool._compact_requested = true @test _ccap(pool.float32.vectors[1]) >= 1_000_000 - AAP._run_auto_compact!(pool) + AAP._run_auto_manage!(pool) @test (@atomic pool._compact_requested) == false # reset (before the work) @test _ccap(pool.float32.vectors[1]) < 1000 # device buffer shrunk in place end # ── Scope-entry hook (dispatches on CuAdaptiveArrayPool) ────────────────── - @testset "_maybe_auto_compact!(::CuAdaptiveArrayPool) fires only at depth 1" begin + @testset "_maybe_auto_manage!(::CuAdaptiveArrayPool) fires only at depth 1" begin pool = _cuda_inactive_bloated(1_000_000, 100) checkpoint!(pool) # depth → 2 @atomic pool._compact_requested = true - AAP._maybe_auto_compact!(pool) # depth 2 ≠ 1 → must NOT act + AAP._maybe_auto_manage!(pool) # depth 2 ≠ 1 → must NOT act @test (@atomic pool._compact_requested) == true # flag preserved for the outer exit @test _ccap(pool.float32.vectors[1]) >= 1_000_000 # not compacted rewind!(pool) # depth → 1 - AAP._maybe_auto_compact!(pool) # depth 1 → fires + AAP._maybe_auto_manage!(pool) # depth 1 → fires @test (@atomic pool._compact_requested) == false # reset @test _ccap(pool.float32.vectors[1]) < 1000 # compacted end - @testset "_maybe_auto_compact! is a no-op when the CUDA flag is clear" begin + @testset "_maybe_auto_manage! is a no-op when the CUDA flag is clear" begin pool = _cuda_inactive_bloated(1_000_000, 100) @test (@atomic pool._compact_requested) == false - AAP._maybe_auto_compact!(pool) + AAP._maybe_auto_manage!(pool) @test _ccap(pool.float32.vectors[1]) >= 1_000_000 # untouched (no request) end @@ -91,8 +91,8 @@ AAP.disable_auto_compact!() # stop the __init__-started timer for deterministi sum(x) end ) - @test occursin("_maybe_auto_compact!", s) # hook emitted… - @test occursin("AUTO_COMPACT", s) # …gated by the compile-time const + @test occursin("_maybe_auto_manage!", s) # hook emitted… + @test occursin("AUTO_MANAGE", s) # …gated by the compile-time const end # ── Auto-registration via the CUDA task-local slow path ─────────────────── @@ -103,12 +103,12 @@ AAP.disable_auto_compact!() # stop the __init__-started timer for deterministi _clear_registry!() end - @testset "end-to-end: bloat auto-compacts at a @with_pool :cuda boundary" begin - AAP.disable_auto_compact!() + @testset "end-to-end: bloat auto-manages at a @with_pool :cuda boundary" begin + AAP.disable_auto_manage!() _clear_registry!() pool = get_task_local_cuda_pool() empty!(pool) # clean slate - AAP.register_auto_compact!(pool) + AAP.register_auto_manage!(pool) @with_pool :cuda p begin # grow slot to 1M high-water x = acquire!(p, Float32, 1_000_000); fill!(x, 0.0f0) end @@ -120,14 +120,14 @@ AAP.disable_auto_compact!() # stop the __init__-started timer for deterministi # Set the request flag directly (deterministic). This exercises the unique end-to-end # piece: the MACRO-emitted hook firing at a real @with_pool :cuda entry and dispatching - # to _maybe_auto_compact!(::CuAdaptiveArrayPool). + # to _maybe_auto_manage!(::CuAdaptiveArrayPool). @atomic pool._compact_requested = true @with_pool :cuda p begin # scope ENTRY at depth 1 → hook fires acquire!(p, Float32, 4) end @test (@atomic pool._compact_requested) == false # hook consumed the request - @test _ccap(pool.float32.vectors[1]) < cap0 # auto-compacted, no manual compact! + @test _ccap(pool.float32.vectors[1]) < cap0 # auto-manageed, no manual compact! empty!(pool) _clear_registry!() end @@ -138,7 +138,7 @@ AAP.disable_auto_compact!() # stop the __init__-started timer for deterministi # tracks pools, not tasks). Each then compacts independently at its OWN scope boundary; # servicing one never touches the other's flag or buffers. @testset "CPU + CUDA coexist: one sweep flags both; each compacts independently" begin - AAP.disable_auto_compact!() + AAP.disable_auto_manage!() _clear_registry!() # CPU pool, inactive-bloated Float64. @@ -148,8 +148,8 @@ AAP.disable_auto_compact!() # stop the __init__-started timer for deterministi # CUDA pool, inactive-bloated Float32. cu = _cuda_inactive_bloated(1_000_000, 100) - AAP.register_auto_compact!(cpu) - AAP.register_auto_compact!(cu) + AAP.register_auto_manage!(cpu) + AAP.register_auto_manage!(cu) @test _registry_len() == 2 cpu_cap0 = _cpucap(cpu.float64.vectors[1]) @@ -158,19 +158,19 @@ AAP.disable_auto_compact!() # stop the __init__-started timer for deterministi @test cu_cap0 >= 1_000_000 # ONE sweep (the single CPU-side Timer body) flags BOTH pools. - AAP._auto_compact_sweep!(nothing) + AAP._auto_manage_sweep!(nothing) @test (@atomic cpu._compact_requested) == true @test (@atomic cu._compact_requested) == true # Service the CPU pool at ITS boundary → only the CPU pool compacts; CUDA untouched. - AAP._maybe_auto_compact!(cpu) + AAP._maybe_auto_manage!(cpu) @test (@atomic cpu._compact_requested) == false # CPU flag consumed @test _cpucap(cpu.float64.vectors[1]) < cpu_cap0 # CPU compacted @test (@atomic cu._compact_requested) == true # CUDA flag still pending @test _ccap(cu.float32.vectors[1]) >= 1_000_000 # CUDA NOT compacted yet # Service the CUDA pool at ITS boundary → now the CUDA pool compacts, on its own. - AAP._maybe_auto_compact!(cu) + AAP._maybe_auto_manage!(cu) @test (@atomic cu._compact_requested) == false # CUDA flag consumed @test _ccap(cu.float32.vectors[1]) < 1000 # CUDA compacted independently @@ -178,4 +178,4 @@ AAP.disable_auto_compact!() # stop the __init__-started timer for deterministi end end -AAP.disable_auto_compact!() # leave the timer stopped for later CUDA test files +AAP.disable_auto_manage!() # leave the timer stopped for later CUDA test files diff --git a/test/metal/runtests.jl b/test/metal/runtests.jl index 4812ae95..f82f5306 100644 --- a/test/metal/runtests.jl +++ b/test/metal/runtests.jl @@ -53,6 +53,6 @@ else include("test_task_local_pool.jl") include("test_trim.jl") include("test_compact.jl") - include("test_auto_compact.jl") + include("test_auto_manage.jl") end end diff --git a/test/metal/test_auto_compact.jl b/test/metal/test_auto_manage.jl similarity index 79% rename from test/metal/test_auto_compact.jl rename to test/metal/test_auto_manage.jl index 9602d67e..bbbc2c56 100644 --- a/test/metal/test_auto_compact.jl +++ b/test/metal/test_auto_manage.jl @@ -1,6 +1,6 @@ # Metal Auto-Compact Tests # ========================= -# Parity with the CPU suite (test/test_auto_compact.jl) for the Metal backend. +# Parity with the CPU suite (test/test_auto_manage.jl) for the Metal backend. # # The single global Timer lives in the BASE module and runs on a CPU thread. It sweeps # the shared `Vector{WeakRef}` registry and sets each live pool's `@atomic _compact_requested` @@ -9,23 +9,23 @@ # pool at `@with_pool :metal`. The two are independent — one sweep flags both, and each # services (and clears) only its own flag at its own boundary. # -# AUTO_COMPACT defaults on, so `__init__` auto-starts the timer; we stop it up front so each +# AUTO_MANAGE defaults on, so `__init__` auto-starts the timer; we stop it up front so each # testset is deterministic. Uses Float32 (Metal's primary type; no Float64 on Metal). import AdaptiveArrayPools as AAP -AAP.disable_auto_compact!() # stop the __init__-started timer for deterministic tests +AAP.disable_auto_manage!() # stop the __init__-started timer for deterministic tests -@testset "Metal auto_compact" begin +@testset "Metal auto_manage" begin # Helpers are scoped INSIDE this testset (a local scope) so they don't define top-level # methods in `Main` that collide with the identically-named helpers in the CPU/CUDA - # auto-compact test files when the full suite loads several backends' files together + # auto-manage test files when the full suite loads several backends' files together # (otherwise: "WARNING: Method definition _clear_registry!() ... overwritten"). _mcap(v) = Int(getfield(v, :maxsize) ÷ sizeof(eltype(v))) # Metal device capacity (elements) _cpucap(v) = length(getfield(v, :ref).mem) # CPU backing capacity (coexistence test) - _clear_registry!() = lock(() -> empty!(AAP._AUTO_COMPACT_REGISTRY), AAP._AUTO_COMPACT_LOCK) - _registry_len() = lock(() -> length(AAP._AUTO_COMPACT_REGISTRY), AAP._AUTO_COMPACT_LOCK) + _clear_registry!() = lock(() -> empty!(AAP._AUTO_MANAGE_REGISTRY), AAP._AUTO_MANAGE_LOCK) + _registry_len() = lock(() -> length(AAP._AUTO_MANAGE_REGISTRY), AAP._AUTO_MANAGE_LOCK) # An inactive, bloated Float32 Metal slot (cap = `big` high-water, last extent = `small`). function _metal_inactive_bloated(big::Int, small::Int) pool = MetalAdaptiveArrayPool{0, METAL_STORAGE}() @@ -45,42 +45,42 @@ AAP.disable_auto_compact!() # stop the __init__-started timer for deterministi @testset "registry sweep flags a registered Metal pool" begin _clear_registry!() p = MetalAdaptiveArrayPool{0, METAL_STORAGE}() - AAP.register_auto_compact!(p) + AAP.register_auto_manage!(p) @test _registry_len() == 1 - AAP._auto_compact_sweep!(nothing) # the timer callback body + AAP._auto_manage_sweep!(nothing) # the timer callback body @test (@atomic p._compact_requested) == true _clear_registry!() end - @testset "_run_auto_compact! resets the flag and compacts inactive Metal bloat" begin + @testset "_run_auto_manage! resets the flag and compacts inactive Metal bloat" begin pool = _metal_inactive_bloated(1_000_000, 100) @atomic pool._compact_requested = true @test _mcap(pool.float32.vectors[1]) >= 1_000_000 - AAP._run_auto_compact!(pool) + AAP._run_auto_manage!(pool) @test (@atomic pool._compact_requested) == false # reset (before the work) @test _mcap(pool.float32.vectors[1]) < 1000 # device buffer shrunk in place end # ── Scope-entry hook (dispatches on MetalAdaptiveArrayPool) ─────────────── - @testset "_maybe_auto_compact!(::MetalAdaptiveArrayPool) fires only at depth 1" begin + @testset "_maybe_auto_manage!(::MetalAdaptiveArrayPool) fires only at depth 1" begin pool = _metal_inactive_bloated(1_000_000, 100) checkpoint!(pool) # depth → 2 @atomic pool._compact_requested = true - AAP._maybe_auto_compact!(pool) # depth 2 ≠ 1 → must NOT act + AAP._maybe_auto_manage!(pool) # depth 2 ≠ 1 → must NOT act @test (@atomic pool._compact_requested) == true # flag preserved for the outer exit @test _mcap(pool.float32.vectors[1]) >= 1_000_000 # not compacted rewind!(pool) # depth → 1 - AAP._maybe_auto_compact!(pool) # depth 1 → fires + AAP._maybe_auto_manage!(pool) # depth 1 → fires @test (@atomic pool._compact_requested) == false # reset @test _mcap(pool.float32.vectors[1]) < 1000 # compacted end - @testset "_maybe_auto_compact! is a no-op when the Metal flag is clear" begin + @testset "_maybe_auto_manage! is a no-op when the Metal flag is clear" begin pool = _metal_inactive_bloated(1_000_000, 100) @test (@atomic pool._compact_requested) == false - AAP._maybe_auto_compact!(pool) + AAP._maybe_auto_manage!(pool) @test _mcap(pool.float32.vectors[1]) >= 1_000_000 # untouched (no request) end @@ -91,8 +91,8 @@ AAP.disable_auto_compact!() # stop the __init__-started timer for deterministi sum(x) end ) - @test occursin("_maybe_auto_compact!", s) # hook emitted… - @test occursin("AUTO_COMPACT", s) # …gated by the compile-time const + @test occursin("_maybe_auto_manage!", s) # hook emitted… + @test occursin("AUTO_MANAGE", s) # …gated by the compile-time const end # ── Auto-registration via the Metal task-local slow path ────────────────── @@ -103,12 +103,12 @@ AAP.disable_auto_compact!() # stop the __init__-started timer for deterministi _clear_registry!() end - @testset "end-to-end: bloat auto-compacts at a @with_pool :metal boundary" begin - AAP.disable_auto_compact!() + @testset "end-to-end: bloat auto-manages at a @with_pool :metal boundary" begin + AAP.disable_auto_manage!() _clear_registry!() pool = get_task_local_metal_pool() empty!(pool) # clean slate - AAP.register_auto_compact!(pool) + AAP.register_auto_manage!(pool) @with_pool :metal p begin # grow slot to 1M high-water x = acquire!(p, Float32, 1_000_000); fill!(x, 0.0f0) end @@ -120,14 +120,14 @@ AAP.disable_auto_compact!() # stop the __init__-started timer for deterministi # Set the request flag directly (deterministic). This exercises the unique end-to-end # piece: the MACRO-emitted hook firing at a real @with_pool :metal entry and dispatching - # to _maybe_auto_compact!(::MetalAdaptiveArrayPool). + # to _maybe_auto_manage!(::MetalAdaptiveArrayPool). @atomic pool._compact_requested = true @with_pool :metal p begin # scope ENTRY at depth 1 → hook fires acquire!(p, Float32, 4) end @test (@atomic pool._compact_requested) == false # hook consumed the request - @test _mcap(pool.float32.vectors[1]) < cap0 # auto-compacted, no manual compact! + @test _mcap(pool.float32.vectors[1]) < cap0 # auto-manageed, no manual compact! empty!(pool) _clear_registry!() end @@ -138,7 +138,7 @@ AAP.disable_auto_compact!() # stop the __init__-started timer for deterministi # tracks pools, not tasks). Each then compacts independently at its OWN scope boundary; # servicing one never touches the other's flag or buffers. @testset "CPU + Metal coexist: one sweep flags both; each compacts independently" begin - AAP.disable_auto_compact!() + AAP.disable_auto_manage!() _clear_registry!() # CPU pool, inactive-bloated Float64. @@ -148,8 +148,8 @@ AAP.disable_auto_compact!() # stop the __init__-started timer for deterministi # Metal pool, inactive-bloated Float32. met = _metal_inactive_bloated(1_000_000, 100) - AAP.register_auto_compact!(cpu) - AAP.register_auto_compact!(met) + AAP.register_auto_manage!(cpu) + AAP.register_auto_manage!(met) @test _registry_len() == 2 cpu_cap0 = _cpucap(cpu.float64.vectors[1]) @@ -158,19 +158,19 @@ AAP.disable_auto_compact!() # stop the __init__-started timer for deterministi @test met_cap0 >= 1_000_000 # ONE sweep (the single CPU-side Timer body) flags BOTH pools. - AAP._auto_compact_sweep!(nothing) + AAP._auto_manage_sweep!(nothing) @test (@atomic cpu._compact_requested) == true @test (@atomic met._compact_requested) == true # Service the CPU pool at ITS boundary → only the CPU pool compacts; Metal untouched. - AAP._maybe_auto_compact!(cpu) + AAP._maybe_auto_manage!(cpu) @test (@atomic cpu._compact_requested) == false # CPU flag consumed @test _cpucap(cpu.float64.vectors[1]) < cpu_cap0 # CPU compacted @test (@atomic met._compact_requested) == true # Metal flag still pending @test _mcap(met.float32.vectors[1]) >= 1_000_000 # Metal NOT compacted yet # Service the Metal pool at ITS boundary → now the Metal pool compacts, on its own. - AAP._maybe_auto_compact!(met) + AAP._maybe_auto_manage!(met) @test (@atomic met._compact_requested) == false # Metal flag consumed @test _mcap(met.float32.vectors[1]) < 1000 # Metal compacted independently @@ -178,4 +178,4 @@ AAP.disable_auto_compact!() # stop the __init__-started timer for deterministi end end -AAP.disable_auto_compact!() # leave the timer stopped for later Metal test files +AAP.disable_auto_manage!() # leave the timer stopped for later Metal test files diff --git a/test/runtests.jl b/test/runtests.jl index 8ec5c0b1..9d4ab5d0 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -3,9 +3,9 @@ using AdaptiveArrayPools using AdaptiveArrayPools: get_typed_pool! import AdaptiveArrayPools: checkpoint!, rewind! -# AUTO_COMPACT defaults on, so __init__ auto-starts the background timer. Stop it so the -# suite is deterministic; testsets that exercise auto-compaction enable it explicitly. -AdaptiveArrayPools.disable_auto_compact!() +# AUTO_MANAGE defaults on, so __init__ auto-starts the background timer. Stop it so the +# suite is deterministic; testsets that exercise auto-manageion enable it explicitly. +AdaptiveArrayPools.disable_auto_manage!() # Version-specific helpers (always defined, even for ARGS path) @static if VERSION >= v"1.12-" @@ -28,7 +28,7 @@ else include("test_state.jl") include("test_trim.jl") include("test_compact.jl") - include("test_auto_compact.jl") + include("test_auto_manage.jl") include("test_multidimensional.jl") include("test_macros.jl") include("test_task_local_pool.jl") @@ -61,7 +61,7 @@ else include("test_state.jl") include("test_trim_legacy.jl") include("test_compact_legacy.jl") - include("test_auto_compact_legacy.jl") + include("test_auto_manage_legacy.jl") include("test_multidimensional.jl") include("test_macros.jl") include("test_task_local_pool.jl") diff --git a/test/test_auto_compact_legacy.jl b/test/test_auto_compact_legacy.jl deleted file mode 100644 index 7ae1f345..00000000 --- a/test/test_auto_compact_legacy.jl +++ /dev/null @@ -1,32 +0,0 @@ -using Test -using AdaptiveArrayPools -using AdaptiveArrayPools: get_task_local_pool - -# Legacy path (Julia < 1.12): the auto-compact API is defined but a NO-OP (the legacy -# pool architecture has no capacity compaction). `const AUTO_COMPACT = false` makes the -# shared @with_pool scope-entry hook DCE away, and the public enable/disable/enabled API -# stays callable across the supported Julia range. Mirrors test_compact_legacy.jl. - -@testset "auto-compact (legacy no-op, Julia < 1.12)" begin - @test AdaptiveArrayPools.AUTO_COMPACT == false - @test auto_compact_enabled() == false - - # enable! warns once and stays a no-op; disable!/enabled are safe and idempotent. - @test (@test_logs (:warn,) match_mode = :any enable_auto_compact!()) === nothing - @test auto_compact_enabled() == false - @test enable_auto_compact!(interval = 5.0, factor = 20, active = false) === nothing - @test disable_auto_compact!() === nothing - @test auto_compact_enabled() == false - - # The internal hooks the shared code references are no-ops too. - @test AdaptiveArrayPools.register_auto_compact!(get_task_local_pool()) === nothing - @test AdaptiveArrayPools._maybe_auto_compact!(get_task_local_pool()) === nothing - - # @with_pool still works on legacy (the hook DCEs to nothing; no auto-compaction). - r = @with_pool pool begin - x = acquire!(pool, Float64, 4) - x .= 1.0 - sum(x) - end - @test r == 4.0 -end diff --git a/test/test_auto_compact.jl b/test/test_auto_manage.jl similarity index 69% rename from test/test_auto_compact.jl rename to test/test_auto_manage.jl index 6305b9ac..0564c085 100644 --- a/test/test_auto_compact.jl +++ b/test/test_auto_manage.jl @@ -6,28 +6,28 @@ import AdaptiveArrayPools as AAP # ============================================================================== # Auto-compact — timer-driven background capacity compaction. -# Design: docs/plans/DESIGN_auto_compact.md +# Design: docs/plans/DESIGN_auto_manage.md # # ONE global Timer sweeps a Vector{WeakRef} registry, setting each live pool's # `@atomic _compact_requested` flag. Each pool's owner task runs the full compact! # at its next `@with_pool` entry (the `_current_depth == 1` safepoint; the macro hook). # -# AUTO_COMPACT defaults to `true`, so __init__ auto-starts the timer and the +# AUTO_MANAGE defaults to `true`, so __init__ auto-starts the timer and the # @with_pool hook is live. We stop that timer up front so each testset controls it # explicitly and the suite stays deterministic. # ============================================================================== -AAP.disable_auto_compact!() # stop the __init__-started timer for deterministic tests +AAP.disable_auto_manage!() # stop the __init__-started timer for deterministic tests -@testset "auto_compact" begin +@testset "auto_manage" begin # Helpers are scoped INSIDE this testset (a local scope) so they don't define top-level # methods in `Main` that collide with identically-named helpers in other test files when # the full suite loads them together — e.g. `_cap` is also defined at the top of # test_compact.jl (otherwise: "WARNING: Method definition _cap(Any) ... overwritten"). _cap(v) = length(getfield(v, :ref).mem) - _clear_registry!() = lock(() -> empty!(AAP._AUTO_COMPACT_REGISTRY), AAP._AUTO_COMPACT_LOCK) - _registry_len() = lock(() -> length(AAP._AUTO_COMPACT_REGISTRY), AAP._AUTO_COMPACT_LOCK) + _clear_registry!() = lock(() -> empty!(AAP._AUTO_MANAGE_REGISTRY), AAP._AUTO_MANAGE_LOCK) + _registry_len() = lock(() -> length(AAP._AUTO_MANAGE_REGISTRY), AAP._AUTO_MANAGE_LOCK) # An inactive, bloated Float64 slot (cap = `big` high-water, last extent = `small`). function _inactive_bloated(big::Int, small::Int) pool = AdaptiveArrayPool{0}() @@ -48,12 +48,12 @@ AAP.disable_auto_compact!() # stop the __init__-started timer for deterministi _clear_registry!() p1 = AdaptiveArrayPool{0}() p2 = AdaptiveArrayPool{0}() - AAP.register_auto_compact!(p1) - AAP.register_auto_compact!(p2) - lock(() -> push!(AAP._AUTO_COMPACT_REGISTRY, WeakRef(nothing)), AAP._AUTO_COMPACT_LOCK) + AAP.register_auto_manage!(p1) + AAP.register_auto_manage!(p2) + lock(() -> push!(AAP._AUTO_MANAGE_REGISTRY, WeakRef(nothing)), AAP._AUTO_MANAGE_LOCK) @test _registry_len() == 3 - AAP._auto_compact_sweep!(nothing) # the timer callback body + AAP._auto_manage_sweep!(nothing) # the timer callback body @test (@atomic p1._compact_requested) == true @test (@atomic p2._compact_requested) == true @@ -61,131 +61,131 @@ AAP.disable_auto_compact!() # stop the __init__-started timer for deterministi _clear_registry!() end - @testset "register_auto_compact! skips flagless pools (no sweep poison)" begin + @testset "register_auto_manage! skips flagless pools (no sweep poison)" begin # A pool without the `_compact_requested` flag (DisabledPool) must NOT be registered: # otherwise the sweep would throw on it and starve every later pool in the registry. _clear_registry!() - AAP.register_auto_compact!(DISABLED_CPU) # DisabledPool: no @atomic flag + AAP.register_auto_manage!(DISABLED_CPU) # DisabledPool: no @atomic flag @test _registry_len() == 0 # silently skipped, not registered # And a real pool registered alongside is still swept normally (no poison). p = AdaptiveArrayPool{0}() - AAP.register_auto_compact!(p) - AAP._auto_compact_sweep!(nothing) + AAP.register_auto_manage!(p) + AAP._auto_manage_sweep!(nothing) @test (@atomic p._compact_requested) == true _clear_registry!() end - @testset "_run_auto_compact! resets the flag and compacts inactive bloat" begin + @testset "_run_auto_manage! resets the flag and compacts inactive bloat" begin pool = _inactive_bloated(1_000_000, 100) @atomic pool._compact_requested = true @test _cap(pool.float64.vectors[1]) >= 1_000_000 - AAP._run_auto_compact!(pool) + AAP._run_auto_manage!(pool) @test (@atomic pool._compact_requested) == false # reset (before the work) @test _cap(pool.float64.vectors[1]) == 150 # shrunk to ceil(1.5 * 100) end - @testset "_run_auto_compact! uses the global config defaults" begin - @test AAP._AUTO_COMPACT_CONFIG.factor == 10 - @test AAP._AUTO_COMPACT_CONFIG.shrink_to == 1.5 - @test AAP._AUTO_COMPACT_CONFIG.min_bytes == 2^20 - @test AAP._AUTO_COMPACT_CONFIG.active == true + @testset "_run_auto_manage! uses the global config defaults" begin + @test AAP._AUTO_MANAGE_CONFIG.factor == 10 + @test AAP._AUTO_MANAGE_CONFIG.shrink_to == 1.5 + @test AAP._AUTO_MANAGE_CONFIG.min_bytes == 2^20 + @test AAP._AUTO_MANAGE_CONFIG.active == true end - # ── Timer lifecycle (enable/disable). AUTO_COMPACT is on → enable does NOT warn ─ + # ── Timer lifecycle (enable/disable). AUTO_MANAGE is on → enable does NOT warn ─ @testset "enable! sets config and starts a timer that flags registered pools" begin _clear_registry!() - AAP.disable_auto_compact!() + AAP.disable_auto_manage!() pool = AdaptiveArrayPool{0}() - AAP.register_auto_compact!(pool) + AAP.register_auto_manage!(pool) - AAP.enable_auto_compact!( + AAP.enable_auto_manage!( interval = 0.1, factor = 20, shrink_to = 2.0, min_bytes = 4096, active = false, ) - @test AAP.auto_compact_enabled() == true - @test AAP._AUTO_COMPACT_CONFIG.factor == 20.0 - @test AAP._AUTO_COMPACT_CONFIG.shrink_to == 2.0 - @test AAP._AUTO_COMPACT_CONFIG.min_bytes == 4096 - @test AAP._AUTO_COMPACT_CONFIG.active == false + @test AAP.auto_manage_enabled() == true + @test AAP._AUTO_MANAGE_CONFIG.factor == 20.0 + @test AAP._AUTO_MANAGE_CONFIG.shrink_to == 2.0 + @test AAP._AUTO_MANAGE_CONFIG.min_bytes == 4096 + @test AAP._AUTO_MANAGE_CONFIG.active == false sleep(0.35) # ~3 ticks @ 0.1s @test (@atomic pool._compact_requested) == true - AAP.disable_auto_compact!() - @test AAP.auto_compact_enabled() == false - AAP._AUTO_COMPACT_CONFIG.factor = 10.0 # restore defaults - AAP._AUTO_COMPACT_CONFIG.shrink_to = 1.5 - AAP._AUTO_COMPACT_CONFIG.min_bytes = 2^20 - AAP._AUTO_COMPACT_CONFIG.active = true + AAP.disable_auto_manage!() + @test AAP.auto_manage_enabled() == false + AAP._AUTO_MANAGE_CONFIG.factor = 10.0 # restore defaults + AAP._AUTO_MANAGE_CONFIG.shrink_to = 1.5 + AAP._AUTO_MANAGE_CONFIG.min_bytes = 2^20 + AAP._AUTO_MANAGE_CONFIG.active = true _clear_registry!() end @testset "disable! stops the timer and is idempotent" begin _clear_registry!() pool = AdaptiveArrayPool{0}() - AAP.register_auto_compact!(pool) - AAP.enable_auto_compact!(interval = 0.1) - AAP.disable_auto_compact!() - @test AAP.auto_compact_enabled() == false + AAP.register_auto_manage!(pool) + AAP.enable_auto_manage!(interval = 0.1) + AAP.disable_auto_manage!() + @test AAP.auto_manage_enabled() == false @atomic pool._compact_requested = false sleep(0.3) @test (@atomic pool._compact_requested) == false # no ticks after disable - AAP.disable_auto_compact!() # idempotent: must not throw - @test AAP.auto_compact_enabled() == false + AAP.disable_auto_manage!() # idempotent: must not throw + @test AAP.auto_manage_enabled() == false _clear_registry!() end @testset "enable! replaces an existing timer (closes the old one)" begin - AAP.disable_auto_compact!() - AAP.enable_auto_compact!(interval = 0.1) - t1 = AAP._AUTO_COMPACT_TIMER[] - AAP.enable_auto_compact!(interval = 0.2) - t2 = AAP._AUTO_COMPACT_TIMER[] + AAP.disable_auto_manage!() + AAP.enable_auto_manage!(interval = 0.1) + t1 = AAP._AUTO_MANAGE_TIMER[] + AAP.enable_auto_manage!(interval = 0.2) + t2 = AAP._AUTO_MANAGE_TIMER[] @test t1 !== t2 @test !isopen(t1) # old timer closed @test isopen(t2) - AAP.disable_auto_compact!() + AAP.disable_auto_manage!() end @testset "a throwing sweep is caught — the timer survives" begin _clear_registry!() bogus = Ref(42) # heap object, NOT a pool - lock(() -> push!(AAP._AUTO_COMPACT_REGISTRY, WeakRef(bogus)), AAP._AUTO_COMPACT_LOCK) + lock(() -> push!(AAP._AUTO_MANAGE_REGISTRY, WeakRef(bogus)), AAP._AUTO_MANAGE_LOCK) # The _safe wrapper catches the sweep's throw (a non-pool in the registry) and @warns # so the timer survives. Silence that EXPECTED warning (NullLogger) so it doesn't read # as a real failure in the suite log; we still assert the wrapper does not rethrow. with_logger(NullLogger()) do - @test (AAP._safe_auto_compact_sweep!(nothing); true) # _safe wrapper swallows the throw + @test (AAP._safe_auto_manage_sweep!(nothing); true) # _safe wrapper swallows the throw end @test bogus[] == 42 # keep `bogus` alive past the sweep _clear_registry!() end # ── Scope-entry hook ───────────────────────────────────────────────────── - @testset "_maybe_auto_compact! fires only at _current_depth == 1" begin + @testset "_maybe_auto_manage! fires only at _current_depth == 1" begin pool = _inactive_bloated(1_000_000, 100) checkpoint!(pool) # depth → 2 @atomic pool._compact_requested = true - AAP._maybe_auto_compact!(pool) # depth 2 ≠ 1 → must NOT act + AAP._maybe_auto_manage!(pool) # depth 2 ≠ 1 → must NOT act @test (@atomic pool._compact_requested) == true # flag preserved for the outer exit @test _cap(pool.float64.vectors[1]) >= 1_000_000 # not compacted rewind!(pool) # depth → 1 - AAP._maybe_auto_compact!(pool) # depth 1 → fires + AAP._maybe_auto_manage!(pool) # depth 1 → fires @test (@atomic pool._compact_requested) == false # reset @test _cap(pool.float64.vectors[1]) == 150 # compacted end - @testset "_maybe_auto_compact! is a no-op when the flag is clear" begin + @testset "_maybe_auto_manage! is a no-op when the flag is clear" begin pool = _inactive_bloated(1_000_000, 100) @test (@atomic pool._compact_requested) == false - AAP._maybe_auto_compact!(pool) + AAP._maybe_auto_manage!(pool) @test _cap(pool.float64.vectors[1]) >= 1_000_000 # untouched (no request) end - @testset "_maybe_auto_compact! no-ops on non-CPU pools (GPU fallback)" begin - @test AAP._maybe_auto_compact!(42) === nothing # ::Any fallback, no throw - @test AAP._maybe_auto_compact!("not a pool") === nothing + @testset "_maybe_auto_manage! no-ops on non-CPU pools (GPU fallback)" begin + @test AAP._maybe_auto_manage!(42) === nothing # ::Any fallback, no throw + @test AAP._maybe_auto_manage!("not a pool") === nothing end @testset "macro wires the gated scope-entry hook into @with_pool" begin @@ -195,21 +195,21 @@ AAP.disable_auto_compact!() # stop the __init__-started timer for deterministi sum(x) end ) - @test occursin("_maybe_auto_compact!", s) # hook emitted… - @test occursin("AUTO_COMPACT", s) # …gated by the compile-time const + @test occursin("_maybe_auto_manage!", s) # hook emitted… + @test occursin("AUTO_MANAGE", s) # …gated by the compile-time const end - # ── Default-on integration (AUTO_COMPACT === true) ─────────────────────── - @testset "AUTO_COMPACT is on by default" begin - @test AAP.AUTO_COMPACT == true + # ── Default-on integration (AUTO_MANAGE === true) ─────────────────────── + @testset "AUTO_MANAGE is on by default" begin + @test AAP.AUTO_MANAGE == true end - @testset "__init__ auto-starts the timer when AUTO_COMPACT is on" begin - AAP.disable_auto_compact!() - @test AAP.auto_compact_enabled() == false + @testset "__init__ auto-starts the timer when AUTO_MANAGE is on" begin + AAP.disable_auto_manage!() + @test AAP.auto_manage_enabled() == false AAP.__init__() # re-run module init - @test AAP.auto_compact_enabled() == true # auto-started - AAP.disable_auto_compact!() + @test AAP.auto_manage_enabled() == true # auto-started + AAP.disable_auto_manage!() end @testset "get_task_local_pool auto-registers the pool (gate on)" begin @@ -219,12 +219,12 @@ AAP.disable_auto_compact!() # stop the __init__-started timer for deterministi _clear_registry!() end - @testset "end-to-end: bloat auto-compacts at a @with_pool boundary" begin - AAP.disable_auto_compact!() + @testset "end-to-end: bloat auto-manages at a @with_pool boundary" begin + AAP.disable_auto_manage!() _clear_registry!() pool = get_task_local_pool() empty!(pool) # clean slate - AAP.register_auto_compact!(pool) + AAP.register_auto_manage!(pool) @with_pool p begin # grow slot to 1M high-water x = acquire!(p, Float64, 1_000_000); x .= 0.0 end @@ -243,7 +243,7 @@ AAP.disable_auto_compact!() # stop the __init__-started timer for deterministi end @test (@atomic pool._compact_requested) == false # hook consumed the request - @test _cap(pool.float64.vectors[1]) < cap0 # auto-compacted, no manual compact! + @test _cap(pool.float64.vectors[1]) < cap0 # auto-manageed, no manual compact! empty!(pool) _clear_registry!() end @@ -253,7 +253,7 @@ AAP.disable_auto_compact!() # stop the __init__-started timer for deterministi # depth 1, regardless of how the previous scope exited — so a scope that early-returns # or breaks (which skip the normal exit path) still gets compacted at its entry. pool = get_task_local_pool() - AAP.disable_auto_compact!() + AAP.disable_auto_manage!() empty!(pool) # early RETURN @with_pool p begin @@ -294,7 +294,7 @@ AAP.disable_auto_compact!() # stop the __init__-started timer for deterministi @testset "@safe_with_pool fires the hook during exception unwind (and preserves it)" begin pool = get_task_local_pool() - AAP.disable_auto_compact!() + AAP.disable_auto_manage!() empty!(pool) @with_pool p begin x = acquire!(p, Float64, 1_000_000); x .= 0.0 @@ -321,4 +321,4 @@ AAP.disable_auto_compact!() # stop the __init__-started timer for deterministi end end -AAP.disable_auto_compact!() # leave the timer stopped for later test files +AAP.disable_auto_manage!() # leave the timer stopped for later test files diff --git a/test/test_auto_manage_legacy.jl b/test/test_auto_manage_legacy.jl new file mode 100644 index 00000000..1b8e2dfd --- /dev/null +++ b/test/test_auto_manage_legacy.jl @@ -0,0 +1,32 @@ +using Test +using AdaptiveArrayPools +using AdaptiveArrayPools: get_task_local_pool + +# Legacy path (Julia < 1.12): the auto-manage API is defined but a NO-OP (the legacy +# pool architecture has no capacity compaction). `const AUTO_MANAGE = false` makes the +# shared @with_pool scope-entry hook DCE away, and the public enable/disable/enabled API +# stays callable across the supported Julia range. Mirrors test_compact_legacy.jl. + +@testset "auto-manage (legacy no-op, Julia < 1.12)" begin + @test AdaptiveArrayPools.AUTO_MANAGE == false + @test auto_manage_enabled() == false + + # enable! warns once and stays a no-op; disable!/enabled are safe and idempotent. + @test (@test_logs (:warn,) match_mode = :any enable_auto_manage!()) === nothing + @test auto_manage_enabled() == false + @test enable_auto_manage!(interval = 5.0, factor = 20, active = false) === nothing + @test disable_auto_manage!() === nothing + @test auto_manage_enabled() == false + + # The internal hooks the shared code references are no-ops too. + @test AdaptiveArrayPools.register_auto_manage!(get_task_local_pool()) === nothing + @test AdaptiveArrayPools._maybe_auto_manage!(get_task_local_pool()) === nothing + + # @with_pool still works on legacy (the hook DCEs to nothing; no auto-manageion). + r = @with_pool pool begin + x = acquire!(pool, Float64, 4) + x .= 1.0 + sum(x) + end + @test r == 4.0 +end From 24586d968f581b8b78b395ee242d5d0dd5d45f0c Mon Sep 17 00:00:00 2001 From: Min-Gu Yoo Date: Tue, 30 Jun 2026 12:18:08 -0700 Subject: [PATCH 02/15] =?UTF-8?q?feat(auto=5Fmanage):=20auto-trim=20?= =?UTF-8?q?=E2=80=94=20periodic=20working-set=20tail=20reclamation=20(CPU)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second action of the auto_manage engine: every `trim_interval`, trim each type's slot tail down to its recent working-set peak, reclaiming the inactive tail that `compact!` only shrinks. Catches the common case of a pool that peaked wide then runs narrow (still entered, so whole-pool-idle schemes never reclaim it). - `_ac_peak_n_active::Int` on TypedPool + BitTypedPool: peak `n_active` since the last trim. One branchless `max` in the claim paths (`_claim_slot!`, `get_bitarray!`), gated by the compile-time `AUTO_MANAGE` const → DCE'd to zero when off (measured ~0.15 ns/acquire, zero-alloc when on). Reset to 0 at each auto-trim, so it doubles as the period accumulator (trim targets the *recent* peak, never the all-time max). - `@atomic _trim_requested::Bool` on AdaptiveArrayPool (+ both ctor paths), parallel to `_compact_requested`. The Timer sets it every `trim_every_ticks = round(trim_interval/interval)` sweeps via a global tick counter. - `_trim_to!(tp, keep)`: generalizes `trim!` (truncate to `keep`, clamped ≥ n_active). - `_run_auto_manage!`: services `_trim_requested` (→ `_auto_trim!`, trim each type to its peak then reset) then `_compact_requested` (→ compact!), each guarded by its flag. The entry hook is unchanged (still triggers on `_compact_requested`, set on the same sweep). `hasfield` guards keep it backend-safe before GPU parity lands. - `enable_auto_manage!(; trim_interval = 120.0)`; `Inf` disables auto-trim. Tests (test/test_auto_trim.jl): peak tracking, `_trim_to!`, auto-trim to the recent peak / to 0 for an unused type, no-op when unflagged, timer cadence, `Inf` disable, zero-alloc hot path, and a RUNTIME_CHECK=1 regression — auto-trim drops the slot reference without swapping the buffer, so an escaped view's poison survives (unlike the compact! poison-undo bug fixed in 988c50f). Tier-1 green. --- src/acquire.jl | 5 ++ src/auto_manage.jl | 83 ++++++++++++++++++---- src/bitarray.jl | 1 + src/state.jl | 15 ++++ src/types.jl | 27 ++++++-- test/runtests.jl | 1 + test/test_auto_trim.jl | 152 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 264 insertions(+), 20 deletions(-) create mode 100644 test/test_auto_trim.jl diff --git a/src/acquire.jl b/src/acquire.jl index a02c03e0..27a4bbf0 100644 --- a/src/acquire.jl +++ b/src/acquire.jl @@ -65,6 +65,10 @@ Returns the slot index. This is the shared primitive for all acquisition paths @inline function _claim_slot!(tp::TypedPool{T}, n::Int) where {T} tp.n_active += 1 idx = tp.n_active + # Auto-trim working-set telemetry: track the peak concurrency since the last trim. + # Branchless `max` on the `n_active` bump; gated by the compile-time const → DCE'd to + # nothing when AUTO_MANAGE is off (measured ~0.15 ns/acquire, zero-alloc, when on). + AUTO_MANAGE && (tp._ac_peak_n_active = max(tp._ac_peak_n_active, idx)) if idx > length(tp.vectors) push!(tp.vectors, allocate_vector(tp, n)) push!(tp.slot_extents, n) # parallel to `vectors`; records this slot's extent @@ -89,6 +93,7 @@ the wrapper points to a different array's memory via `setfield!(:ref)`. @inline function _claim_slot!(tp::TypedPool{T}) where {T} tp.n_active += 1 idx = tp.n_active + AUTO_MANAGE && (tp._ac_peak_n_active = max(tp._ac_peak_n_active, idx)) # working-set peak (DCE'd off) if idx > length(tp.vectors) push!(tp.vectors, Vector{T}(undef, 0)) push!(tp.slot_extents, 0) # placeholder slot: backing points elsewhere (reshape!) diff --git a/src/auto_manage.jl b/src/auto_manage.jl index c2c08bec..bdbc90dd 100644 --- a/src/auto_manage.jl +++ b/src/auto_manage.jl @@ -39,15 +39,21 @@ const AUTO_MANAGE = @load_preference("auto_manage", true)::Bool const _AUTO_MANAGE_REGISTRY = WeakRef[] const _AUTO_MANAGE_LOCK = ReentrantLock() -# Mutable global config, set by `enable_auto_manage!`, read by `_run_auto_manage!`. -# Mirrors the `compact!` knobs; `factor`/`shrink_to` are `Float64` for stable storage. +# Mutable global config, set by `enable_auto_manage!`, read by the sweep / `_run_auto_manage!`. +# Mirrors the `compact!` knobs (`factor`/`shrink_to` `Float64` for stable storage), plus the +# auto-trim cadence `trim_every_ticks` (auto-trim fires every N sweep ticks; 0 = never trim). mutable struct _AutoManageConfig factor::Float64 shrink_to::Float64 min_bytes::Int active::Bool + trim_every_ticks::Int end -const _AUTO_MANAGE_CONFIG = _AutoManageConfig(10.0, 1.5, 2^20, true) +const _AUTO_MANAGE_CONFIG = _AutoManageConfig(10.0, 1.5, 2^20, true, 0) + +# Sweep tick counter driving the auto-trim cadence. Owned by the single Timer thread +# (incremented/reset only inside the sweep), so a plain `Ref` is sufficient. +const _AUTO_MANAGE_TICK = Ref{Int}(0) """ register_auto_manage!(pool) @@ -74,6 +80,17 @@ end # wraps it in try/catch (Phase 2) — a throwing callback silently kills a Julia Timer. function _auto_manage_sweep!(_timer) lock(_AUTO_MANAGE_LOCK) do + # Auto-trim cadence: fire every `trim_every_ticks` sweeps (0 = never). Computed + # under the same lock that guards the config so a concurrent `enable!` can't tear it. + trim_every = _AUTO_MANAGE_CONFIG.trim_every_ticks + do_trim = false + if trim_every > 0 + _AUTO_MANAGE_TICK[] += 1 + if _AUTO_MANAGE_TICK[] >= trim_every + _AUTO_MANAGE_TICK[] = 0 + do_trim = true + end + end i = 1 while i <= length(_AUTO_MANAGE_REGISTRY) pool = _AUTO_MANAGE_REGISTRY[i].value @@ -82,7 +99,11 @@ function _auto_manage_sweep!(_timer) pop!(_AUTO_MANAGE_REGISTRY) # dead → swap-remove else p = pool::AbstractArrayPool # CPU or any GPU backend pool - @atomic :monotonic p._compact_requested = true # request compaction + @atomic :monotonic p._compact_requested = true # request compaction (every tick) + # request auto-trim (every `trim_every` ticks); guard backends without the flag + if do_trim && hasfield(typeof(p), :_trim_requested) + @atomic :monotonic p._trim_requested = true + end i += 1 end end @@ -98,16 +119,46 @@ end # Returns nothing; the summary is discarded (no allocation). Shared across backends: # `compact!` takes the same factor/shrink_to/min_bytes/active kwargs for every # `AbstractArrayPool`, so one implementation drives CPU and GPU pools alike. +# Auto-trim one pool: drop each type's cold slot tail down to its recent working-set peak +# (`_ac_peak_n_active`), then reset that peak for the next observation period. Generic over +# `foreach_fixed_slot` + `others`, so it serves every backend once its typed pools carry the +# field. Drops references only (no buffer swap), so `RUNTIME_CHECK` poison on a dropped slot +# survives for any escaped view. +function _auto_trim!(pool::AbstractArrayPool) + foreach_fixed_slot(pool) do tp + _trim_to!(tp, tp._ac_peak_n_active) + tp._ac_peak_n_active = 0 + end + for tp in values(pool.others) + _trim_to!(tp, tp._ac_peak_n_active) + tp._ac_peak_n_active = 0 + end + return nothing +end + function _run_auto_manage!(pool::AbstractArrayPool) - @atomic :monotonic pool._compact_requested = false - factor, shrink_to, min_bytes, active = lock(_AUTO_MANAGE_LOCK) do - cfg = _AUTO_MANAGE_CONFIG - (cfg.factor, cfg.shrink_to, cfg.min_bytes, cfg.active) + # Auto-trim first (when flagged): reclaim the cold tail before compacting the kept slots. + # The `hasfield` guard const-folds per concrete type → a backend without the flag no-ops. + # Each action resets its flag BEFORE the work so a concurrent sweep's re-request is kept. + if hasfield(typeof(pool), :_trim_requested) && (@atomic :monotonic pool._trim_requested) + @atomic :monotonic pool._trim_requested = false + try + _auto_trim!(pool) + catch e + @warn "auto-trim failed; skipping this cycle" exception = (e, catch_backtrace()) maxlog = 3 + end end - try - compact!(pool; factor, shrink_to, min_bytes, active) - catch e - @warn "auto-manage failed; skipping this cycle" exception = (e, catch_backtrace()) maxlog = 3 + if (@atomic :monotonic pool._compact_requested) + @atomic :monotonic pool._compact_requested = false + factor, shrink_to, min_bytes, active = lock(_AUTO_MANAGE_LOCK) do + cfg = _AUTO_MANAGE_CONFIG + (cfg.factor, cfg.shrink_to, cfg.min_bytes, cfg.active) + end + try + compact!(pool; factor, shrink_to, min_bytes, active) + catch e + @warn "auto-manage failed; skipping this cycle" exception = (e, catch_backtrace()) maxlog = 3 + end end return nothing end @@ -199,15 +250,19 @@ See also [`disable_auto_manage!`](@ref), [`auto_manage_enabled`](@ref). """ function enable_auto_manage!(; interval::Real = 30.0, factor::Real = 10, shrink_to::Real = 1.5, - min_bytes::Int = 2^20, active::Bool = true, + min_bytes::Int = 2^20, active::Bool = true, trim_interval::Real = 120.0, ) - AUTO_MANAGE || @warn "enable_auto_manage!: the `auto_manage` Preference is off — the `@with_pool` hook is compiled out, so the timer's flags are never serviced and no automatic compaction happens (registering pools does not change this). Set the Preference and restart to activate, or call `compact!` manually." maxlog = 1 + AUTO_MANAGE || @warn "enable_auto_manage!: the `auto_manage` Preference is off — the `@with_pool` hook is compiled out, so the timer's flags are never serviced and no automatic management happens (registering pools does not change this). Set the Preference and restart to activate, or call `compact!`/`trim!` manually." maxlog = 1 + # auto-trim cadence in sweep ticks; non-finite `trim_interval` (e.g. Inf) disables auto-trim. + trim_every = isfinite(trim_interval) ? max(1, round(Int, trim_interval / interval)) : 0 lock(_AUTO_MANAGE_LOCK) do cfg = _AUTO_MANAGE_CONFIG cfg.factor = Float64(factor) cfg.shrink_to = Float64(shrink_to) cfg.min_bytes = min_bytes cfg.active = active + cfg.trim_every_ticks = trim_every + _AUTO_MANAGE_TICK[] = 0 # restart the trim cadence from this enable! end _start_auto_manage_timer!(interval) return nothing diff --git a/src/bitarray.jl b/src/bitarray.jl index 89776e7a..08f18e5f 100644 --- a/src/bitarray.jl +++ b/src/bitarray.jl @@ -53,6 +53,7 @@ function get_bitarray!(tp::BitTypedPool, dims::NTuple{N, Int}) where {N} total_len = safe_prod(dims) tp.n_active += 1 idx = tp.n_active + AUTO_MANAGE && (tp._ac_peak_n_active = max(tp._ac_peak_n_active, idx)) # working-set peak (DCE'd off) # 1. Pool expansion needed (new slot) if idx > length(tp.vectors) diff --git a/src/state.jl b/src/state.jl index e9c0fbfe..40ea58a6 100644 --- a/src/state.jl +++ b/src/state.jl @@ -814,6 +814,21 @@ function _trim_counts!(tp::AbstractTypedPool)::NTuple{3, Int} return (max(0, released), wrappers_released, bytes) end +# Trim one typed pool down to exactly `keep` slots — the generalization of `_trim_counts!` +# (which keeps `n_active`) used by auto-trim to drop the cold tail beyond the recent +# working-set peak. Clamped to `≥ n_active` so it never drops a slot still in use (auto-trim +# calls it at `depth == 1` where `n_active == 0`, so this only guards misuse). Drops +# references only (no buffer swap), so under `RUNTIME_CHECK` a dropped poisoned slot keeps its +# poison for any escaped view. Returns nothing (the byte summary is for the public `trim!`). +function _trim_to!(tp::AbstractTypedPool, keep::Int) + keep = max(keep, tp.n_active) + length(tp.vectors) <= keep && return nothing + _trim_inactive_wrappers!(tp, keep) + resize!(tp.vectors, keep) + _trim_slot_extents!(tp, keep) + return nothing +end + # Sum `_trim_counts!` over every fixed slot, unrolled at compile time. A plain # `foreach_fixed_slot(pool) do tp ... end` closure would box the accumulators # (Core.Box) and widen the summary to `Any`; this unrolled fold stays concrete diff --git a/src/types.jl b/src/types.jl index 0217f731..9bfe4c0f 100644 --- a/src/types.jl +++ b/src/types.jl @@ -125,6 +125,12 @@ mutable struct TypedPool{T} <: AbstractTypedPool{T, Vector{T}} n_active::Int _checkpoint_n_active::Vector{Int} # Saved n_active at each checkpoint _checkpoint_depths::Vector{Int} # Depth of each checkpoint + + # --- Auto-trim telemetry --- + # Peak `n_active` reached since the last auto-trim — the recent working-set width. + # Written on the hot path (`_claim_slot!`, one `max`, gated by AUTO_MANAGE → DCE'd off); + # auto-trim reads it as the slot count to keep, then resets it to 0. Owner-only (non-atomic). + _ac_peak_n_active::Int end TypedPool{T}() where {T} = TypedPool{T}( @@ -137,7 +143,8 @@ TypedPool{T}() where {T} = TypedPool{T}( # State Management (1-based sentinel pattern: guaranteed non-empty) 0, # n_active [0], # _checkpoint_n_active: sentinel (n_active=0 at depth=0) - [0] # _checkpoint_depths: sentinel (depth=0 = no checkpoint) + [0], # _checkpoint_depths: sentinel (depth=0 = no checkpoint) + 0, # _ac_peak_n_active: no usage observed yet ) # ============================================================================== @@ -241,6 +248,9 @@ mutable struct BitTypedPool <: AbstractTypedPool{Bool, BitVector} n_active::Int _checkpoint_n_active::Vector{Int} _checkpoint_depths::Vector{Int} + + # --- Auto-trim telemetry (parallel to TypedPool; see its docstring) --- + _ac_peak_n_active::Int end BitTypedPool() = BitTypedPool( @@ -251,7 +261,8 @@ BitTypedPool() = BitTypedPool( # State Management (1-based sentinel pattern) 0, # n_active [0], # _checkpoint_n_active: sentinel - [0] # _checkpoint_depths: sentinel + [0], # _checkpoint_depths: sentinel + 0, # _ac_peak_n_active ) # ============================================================================== @@ -384,10 +395,12 @@ mutable struct AdaptiveArrayPool{S} <: AbstractArrayPool _pending_return_site::String # "" = no pending; set by macro before validate _borrow_log::Union{Nothing, IdDict{Any, String}} # vector_obj => callsite string - # Auto-compact request flag: set by the background Timer sweep (a different - # thread), read + reset by the owner task at the `_current_depth == 1` safepoint. - # Atomic for the cross-thread handoff; ignored when auto-manage is disabled. + # Auto-manage request flags: set by the background Timer sweep (a different thread), + # read + reset by the owner task at the `_current_depth == 1` safepoint. Atomic for the + # cross-thread handoff; ignored when auto-manage is disabled. `_compact_requested` is set + # every tick (→ compact!); `_trim_requested` every `trim_interval` (→ auto-trim the tail). @atomic _compact_requested::Bool + @atomic _trim_requested::Bool end function AdaptiveArrayPool{S}() where {S} @@ -410,7 +423,8 @@ function AdaptiveArrayPool{S}() where {S} "", # _pending_callsite: no pending "", # _pending_return_site: no pending nothing, # _borrow_log: lazily created at S=1 - false, # _compact_requested: no pending auto-manage request + false, # _compact_requested: no pending compact request + false, # _trim_requested: no pending auto-trim request ) end @@ -464,6 +478,7 @@ _make_pool(runtime_check::Bool, old::AdaptiveArrayPool) = _make_pool(Int(runtime "", # _pending_return_site: reset S >= 1 ? IdDict{Any, String}() : nothing, # _borrow_log false, # _compact_requested: reset on migration + false, # _trim_requested: reset on migration ) level == 0 && return _new(Val(0)) return _new(Val(1)) diff --git a/test/runtests.jl b/test/runtests.jl index 9d4ab5d0..c2227c73 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -29,6 +29,7 @@ else include("test_trim.jl") include("test_compact.jl") include("test_auto_manage.jl") + include("test_auto_trim.jl") include("test_multidimensional.jl") include("test_macros.jl") include("test_task_local_pool.jl") diff --git a/test/test_auto_trim.jl b/test/test_auto_trim.jl new file mode 100644 index 00000000..4242d4a7 --- /dev/null +++ b/test/test_auto_trim.jl @@ -0,0 +1,152 @@ +using Test +using AdaptiveArrayPools +using AdaptiveArrayPools: checkpoint!, rewind! +import AdaptiveArrayPools as AAP + +# ============================================================================== +# Auto-trim — periodic working-set tail reclamation (the second action of the +# unified auto_manage engine). Design: docs/plans/DESIGN_auto_trim.md +# +# A `TypedPool` records the peak `n_active` reached since the last auto-trim in +# `_ac_peak_n_active` (one hot-path `max` in `_claim_slot!`, gated by AUTO_MANAGE). +# Every `trim_interval`, the Timer sets the pool's `@atomic _trim_requested`; the +# owner services it at a `@with_pool` entry — trimming each type's slot tail down +# to that recent peak (a type unused for the period trims to 0), then resetting it. +# +# AUTO_MANAGE defaults on; we stop the __init__ timer up front for determinism. +# ============================================================================== + +AAP.disable_auto_manage!() + +@testset "auto_trim" begin + + _clear_registry!() = lock(() -> empty!(AAP._AUTO_MANAGE_REGISTRY), AAP._AUTO_MANAGE_LOCK) + + # ── Data layer ─────────────────────────────────────────────────────────── + @testset "TypedPool._ac_peak_n_active (default 0) + pool._trim_requested (default false)" begin + pool = AdaptiveArrayPool{0}() + @test pool.float64._ac_peak_n_active == 0 + @test (@atomic pool._trim_requested) == false + @atomic pool._trim_requested = true + @test (@atomic pool._trim_requested) == true + end + + @testset "_claim_slot! records the peak n_active in _ac_peak_n_active" begin + pool = AdaptiveArrayPool{0}() + checkpoint!(pool) + acquire!(pool, Float64, 4); acquire!(pool, Float64, 4); acquire!(pool, Float64, 4) # n_active → 3 + rewind!(pool) + @test pool.float64._ac_peak_n_active == 3 # peak captured (not reset by rewind) + # a narrower second period after a manual reset stays at the new peak + pool.float64._ac_peak_n_active = 0 + checkpoint!(pool); acquire!(pool, Float64, 4); rewind!(pool) + @test pool.float64._ac_peak_n_active == 1 + end + + # ── _trim_to! primitive ────────────────────────────────────────────────── + @testset "_trim_to!(tp, keep) truncates vectors/wrappers/slot_extents; slot regrows" begin + pool = AdaptiveArrayPool{0}() + checkpoint!(pool) + acquire!(pool, Float64, 100); acquire!(pool, Float64, 100); acquire!(pool, Float64, 100) + rewind!(pool) + tp = pool.float64 + @test length(tp.vectors) == 3 && length(tp.slot_extents) == 3 + + AAP._trim_to!(tp, 1) + @test length(tp.vectors) == 1 + @test length(tp.slot_extents) == 1 + + checkpoint!(pool); a = acquire!(pool, Float64, 60); a .= 7.0; rewind!(pool) # regrow slot 1 + @test length(a) == 60 && all(a .== 7.0) + AAP._trim_to!(tp, 0) # trim everything + @test length(tp.vectors) == 0 + end + + # ── Service: auto-trim to the recent peak ──────────────────────────────── + @testset "_run_auto_manage! trims each type's tail to its recent peak, then resets" begin + pool = AdaptiveArrayPool{0}() + checkpoint!(pool) # wide: 3 concurrent + acquire!(pool, Float64, 100); acquire!(pool, Float64, 100); acquire!(pool, Float64, 100) + rewind!(pool) + pool.float64._ac_peak_n_active = 0 # start a fresh observation period + checkpoint!(pool); acquire!(pool, Float64, 100); rewind!(pool) # narrowed: peak = 1 + @test pool.float64._ac_peak_n_active == 1 + @test length(pool.float64.vectors) == 3 # tail still retained + + @atomic pool._trim_requested = true + AAP._run_auto_manage!(pool) + + @test length(pool.float64.vectors) == 1 # trimmed to the recent peak + @test pool.float64._ac_peak_n_active == 0 # reset for the next period + @test (@atomic pool._trim_requested) == false # flag consumed + end + + @testset "a type unused for the period trims to 0" begin + pool = AdaptiveArrayPool{0}() + checkpoint!(pool); acquire!(pool, Int64, 1000); acquire!(pool, Int64, 1000); rewind!(pool) + pool.int64._ac_peak_n_active = 0 # new period, then NO Int64 use + @test length(pool.int64.vectors) == 2 + @atomic pool._trim_requested = true + AAP._run_auto_manage!(pool) + @test length(pool.int64.vectors) == 0 # unused type fully dropped + end + + @testset "no-op when _trim_requested is clear" begin + pool = AdaptiveArrayPool{0}() + checkpoint!(pool); acquire!(pool, Float64, 100); acquire!(pool, Float64, 100); rewind!(pool) + pool.float64._ac_peak_n_active = 0 + AAP._run_auto_manage!(pool) # no flag set + @test length(pool.float64.vectors) == 2 # untouched + end + + # ── Overhead + safety ──────────────────────────────────────────────────── + @testset "the working-set `max` keeps acquire! zero-alloc" begin + pool = AdaptiveArrayPool{0}() + g(p) = (checkpoint!(p); acquire!(p, Float64, 8); acquire!(p, Float64, 8); rewind!(p); nothing) + g(pool); g(pool) # warmup + @test @allocated(g(pool)) == 0 # the max adds no allocation + @test pool.float64._ac_peak_n_active >= 2 # …and it is tracking the peak + end + + @testset "RUNTIME_CHECK=1: auto-trim drops the slot ref, preserving poison (no buffer swap)" begin + pool = AdaptiveArrayPool{1}() # runtime checks ON + checkpoint!(pool); a = acquire!(pool, Float64, 100); a .= 1.0; rewind!(pool) # released → poisoned + tp = pool.float64 + @test length(tp.vectors) == 1 + backing = tp.vectors[1] # the poisoned backing an escaped view would hold + mem_before = getfield(backing, :ref).mem + pool.float64._ac_peak_n_active = 0 # unused this period → trim to 0 + @atomic pool._trim_requested = true + AAP._run_auto_manage!(pool) + @test length(tp.vectors) == 0 # slot reference dropped + @test getfield(backing, :ref).mem === mem_before # buffer NOT swapped → poison intact (unlike compact!) + end + + # ── Config + timer cadence ─────────────────────────────────────────────── + @testset "enable_auto_manage! accepts trim_interval; timer flags trim every period" begin + _clear_registry!() + AAP.disable_auto_manage!() + pool = AdaptiveArrayPool{0}() + AAP.register_auto_manage!(pool) + # interval 0.1s, trim every 0.2s (K=2 ticks): within ~0.5s, _trim_requested fires + AAP.enable_auto_manage!(interval = 0.1, trim_interval = 0.2) + sleep(0.55) + @test (@atomic pool._trim_requested) == true + AAP.disable_auto_manage!() + _clear_registry!() + end + + @testset "trim_interval = Inf disables auto-trim (compact-only)" begin + _clear_registry!() + AAP.disable_auto_manage!() + pool = AdaptiveArrayPool{0}() + AAP.register_auto_manage!(pool) + AAP.enable_auto_manage!(interval = 0.1, trim_interval = Inf) + sleep(0.35) + @test (@atomic pool._trim_requested) == false # never flagged for trim + AAP.disable_auto_manage!() + _clear_registry!() + end +end + +AAP.disable_auto_manage!() From ab9be385c5f95ad50040ab4f5c5064698fe26098 Mon Sep 17 00:00:00 2001 From: Min-Gu Yoo Date: Tue, 30 Jun 2026 13:23:51 -0700 Subject: [PATCH 03/15] feat(auto_manage): auto-trim Metal parity Mirror the CPU auto-trim data layer (commit 24586d9) onto the Metal backend. The service layer (`_trim_to!`, `_auto_trim!`, `_run_auto_manage!`) is generic over the abstract pool supertypes and already worked for Metal, so this is purely the two telemetry fields plus the claim-path `max`: - `_ac_peak_n_active::Int` on MetalTypedPool: peak `n_active` since the last trim. One branchless `max` in both `_metal_claim_slot!` methods, gated by the compile-time AUTO_MANAGE const -> DCE'd to nothing when off (one Int write when on). - `@atomic _trim_requested::Bool` on MetalAdaptiveArrayPool (+ ctor), parallel to `_compact_requested`. The base-module Timer sweep's `hasfield` guard now const-folds true for Metal pools, so the sweep flags trim every `trim_interval`; the unchanged entry hook (fires on `_compact_requested`, set on the same sweep) lets the generic `_run_auto_manage!` service both at the `@with_pool :metal` safepoint. Tests (test/metal/test_auto_trim.jl): the CPU suite mirrored for Metal -- peak tracking through `_metal_claim_slot!`, `_trim_to!` truncation + regrow, auto-trim to the recent peak / to 0 for an unused type, no-op when unflagged, an R=1 no-buffer-swap guard (the dropped slot's MtlArray DataRef is left intact, unlike compact!), and the sweep flagging `_trim_requested` on a registered Metal pool. Full Metal suite green locally (57s). --- ext/AdaptiveArrayPoolsMetalExt/acquire.jl | 5 + ext/AdaptiveArrayPoolsMetalExt/types.jl | 16 ++- test/metal/runtests.jl | 1 + test/metal/test_auto_trim.jl | 153 ++++++++++++++++++++++ 4 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 test/metal/test_auto_trim.jl diff --git a/ext/AdaptiveArrayPoolsMetalExt/acquire.jl b/ext/AdaptiveArrayPoolsMetalExt/acquire.jl index 2c2a0560..952ba695 100644 --- a/ext/AdaptiveArrayPoolsMetalExt/acquire.jl +++ b/ext/AdaptiveArrayPoolsMetalExt/acquire.jl @@ -78,6 +78,10 @@ Metal.jl's resize! unnecessarily (especially after safety invalidation sets dims @inline function _metal_claim_slot!(tp::MetalTypedPool{T, S}, total_len::Int) where {T, S} tp.n_active += 1 idx = tp.n_active + # Auto-trim working-set telemetry (parity with CPU `_claim_slot!`): track the peak + # concurrency since the last trim. Branchless `max`, gated by the compile-time const → + # DCE'd to nothing when AUTO_MANAGE is off (one `Int` field write when on). + AdaptiveArrayPools.AUTO_MANAGE && (tp._ac_peak_n_active = max(tp._ac_peak_n_active, idx)) if idx > length(tp.vectors) push!(tp.vectors, allocate_vector(tp, total_len)) push!(tp.slot_extents, total_len) # parallel to `vectors` (for compact!/_slot_used) @@ -99,6 +103,7 @@ the wrapper points to a different array's memory via `setfield!(:data)`. @inline function _metal_claim_slot!(tp::MetalTypedPool{T, S}) where {T, S} tp.n_active += 1 idx = tp.n_active + AdaptiveArrayPools.AUTO_MANAGE && (tp._ac_peak_n_active = max(tp._ac_peak_n_active, idx)) # working-set peak (DCE'd off) if idx > length(tp.vectors) push!(tp.vectors, MtlArray{T, 1, S}(undef, 0)) push!(tp.slot_extents, 0) # placeholder slot: wrapper points elsewhere (reshape!) diff --git a/ext/AdaptiveArrayPoolsMetalExt/types.jl b/ext/AdaptiveArrayPoolsMetalExt/types.jl index f756becf..9ccf10e5 100644 --- a/ext/AdaptiveArrayPoolsMetalExt/types.jl +++ b/ext/AdaptiveArrayPoolsMetalExt/types.jl @@ -43,6 +43,12 @@ mutable struct MetalTypedPool{T, S} <: AbstractTypedPool{T, MtlArray{T, 1, S}} n_active::Int _checkpoint_n_active::Vector{Int} _checkpoint_depths::Vector{Int} + + # --- Auto-trim telemetry (parity with CPU TypedPool; see its docstring) --- + # Peak `n_active` since the last auto-trim — the recent working-set width. Written on the + # hot path (`_metal_claim_slot!`, one `max`, gated by AUTO_MANAGE → DCE'd off); auto-trim + # reads it as the slot count to keep, then resets it to 0. Owner-only (non-atomic). + _ac_peak_n_active::Int end function MetalTypedPool{T, S}() where {T, S} @@ -51,6 +57,7 @@ function MetalTypedPool{T, S}() where {T, S} Union{Nothing, Vector{Any}}[], # arr_wrappers (indexed by N) Int[], # slot_extents (parallel to vectors) 0, [0], [0], # State (1-based sentinel) + 0, # _ac_peak_n_active: no usage observed yet ) end @@ -122,10 +129,14 @@ mutable struct MetalAdaptiveArrayPool{R, S} <: AbstractArrayPool _pending_return_site::String _borrow_log::Union{Nothing, IdDict{Any, String}} - # Auto-compact request flag (parity with CPU AdaptiveArrayPool): set by the base + # Auto-manage request flags (parity with CPU AdaptiveArrayPool): set by the base # module's background Timer sweep (a different thread), read + reset by the owner task # at the `@with_pool :metal` entry safepoint. Atomic for the cross-thread handoff. + # `_compact_requested` is set every tick (→ compact!); `_trim_requested` every + # `trim_interval` (→ auto-trim the tail). The entry hook fires on `_compact_requested` + # (set on the same sweep), so the generic `_run_auto_manage!` services both at once. @atomic _compact_requested::Bool + @atomic _trim_requested::Bool end function MetalAdaptiveArrayPool{R, S}() where {R, S} @@ -144,7 +155,8 @@ function MetalAdaptiveArrayPool{R, S}() where {R, S} "", # _pending_callsite "", # _pending_return_site nothing, # _borrow_log: lazily created when R >= 1 - false, # _compact_requested: no pending auto-manage request + false, # _compact_requested: no pending compact request + false, # _trim_requested: no pending auto-trim request ) end diff --git a/test/metal/runtests.jl b/test/metal/runtests.jl index f82f5306..2ff14de5 100644 --- a/test/metal/runtests.jl +++ b/test/metal/runtests.jl @@ -54,5 +54,6 @@ else include("test_trim.jl") include("test_compact.jl") include("test_auto_manage.jl") + include("test_auto_trim.jl") end end diff --git a/test/metal/test_auto_trim.jl b/test/metal/test_auto_trim.jl new file mode 100644 index 00000000..425cfc2e --- /dev/null +++ b/test/metal/test_auto_trim.jl @@ -0,0 +1,153 @@ +# Metal Auto-Trim Tests +# ====================== +# Parity with the CPU suite (test/test_auto_trim.jl) for the Metal backend — the second +# action of the unified auto_manage engine. Design: docs/plans/DESIGN_auto_trim.md +# +# A `MetalTypedPool` records the peak `n_active` reached since the last auto-trim in +# `_ac_peak_n_active` (one hot-path `max` in `_metal_claim_slot!`, gated by AUTO_MANAGE). +# Every `trim_interval`, the base-module Timer sets the pool's `@atomic _trim_requested`; +# the owner services it at a `@with_pool :metal` entry — trimming each type's slot tail +# down to that recent peak (a type unused for the period trims to 0), then resetting it. +# +# The service layer (`_trim_to!`, `_auto_trim!`, `_run_auto_manage!`) is generic over +# `AbstractTypedPool`/`AbstractArrayPool`, so this backend needs only the two data-layer +# fields + the claim-path `max`. Uses Float32 (Metal's primary type; no Float64 on Metal). + +import AdaptiveArrayPools as AAP + +AAP.disable_auto_manage!() # stop the __init__-started timer for deterministic tests + +@testset "Metal auto_trim" begin + + # Scoped INSIDE the testset (local scope) so the helper doesn't define a top-level + # `Main` method colliding with the identically-named helper in the CPU/CUDA auto-trim + # files when the full suite loads several backends together. + _clear_registry!() = lock(() -> empty!(AAP._AUTO_MANAGE_REGISTRY), AAP._AUTO_MANAGE_LOCK) + _registry_len() = lock(() -> length(AAP._AUTO_MANAGE_REGISTRY), AAP._AUTO_MANAGE_LOCK) + + # ── Data layer ─────────────────────────────────────────────────────────── + @testset "MetalTypedPool._ac_peak_n_active (default 0) + pool._trim_requested (default false)" begin + pool = MetalAdaptiveArrayPool{0, METAL_STORAGE}() + @test pool.float32._ac_peak_n_active == 0 + @test (@atomic pool._trim_requested) == false + @atomic pool._trim_requested = true + @test (@atomic pool._trim_requested) == true + end + + @testset "_metal_claim_slot! records the peak n_active in _ac_peak_n_active" begin + pool = MetalAdaptiveArrayPool{0, METAL_STORAGE}() + checkpoint!(pool) + acquire!(pool, Float32, 4); acquire!(pool, Float32, 4); acquire!(pool, Float32, 4) # n_active → 3 + rewind!(pool) + @test pool.float32._ac_peak_n_active == 3 # peak captured (not reset by rewind) + # a narrower second period after a manual reset stays at the new peak + pool.float32._ac_peak_n_active = 0 + checkpoint!(pool); acquire!(pool, Float32, 4); rewind!(pool) + @test pool.float32._ac_peak_n_active == 1 + end + + # ── _trim_to! primitive (generic, dispatches on MetalTypedPool) ─────────── + @testset "_trim_to!(tp, keep) truncates vectors/wrappers/slot_extents; slot regrows" begin + pool = MetalAdaptiveArrayPool{0, METAL_STORAGE}() + checkpoint!(pool) + acquire!(pool, Float32, 100); acquire!(pool, Float32, 100); acquire!(pool, Float32, 100) + rewind!(pool) + tp = pool.float32 + @test length(tp.vectors) == 3 && length(tp.slot_extents) == 3 + + AAP._trim_to!(tp, 1) + @test length(tp.vectors) == 1 + @test length(tp.slot_extents) == 1 + + checkpoint!(pool); a = acquire!(pool, Float32, 60); fill!(a, 7.0f0); rewind!(pool) # regrow slot 1 + @test length(a) == 60 && all(==(7.0f0), Array(a)) + AAP._trim_to!(tp, 0) # trim everything + @test length(tp.vectors) == 0 + end + + # ── Service: auto-trim to the recent peak ──────────────────────────────── + @testset "_run_auto_manage! trims each type's tail to its recent peak, then resets" begin + pool = MetalAdaptiveArrayPool{0, METAL_STORAGE}() + checkpoint!(pool) # wide: 3 concurrent + acquire!(pool, Float32, 100); acquire!(pool, Float32, 100); acquire!(pool, Float32, 100) + rewind!(pool) + pool.float32._ac_peak_n_active = 0 # start a fresh observation period + checkpoint!(pool); acquire!(pool, Float32, 100); rewind!(pool) # narrowed: peak = 1 + @test pool.float32._ac_peak_n_active == 1 + @test length(pool.float32.vectors) == 3 # tail still retained + + @atomic pool._trim_requested = true + AAP._run_auto_manage!(pool) + + @test length(pool.float32.vectors) == 1 # trimmed to the recent peak + @test pool.float32._ac_peak_n_active == 0 # reset for the next period + @test (@atomic pool._trim_requested) == false # flag consumed + end + + @testset "a type unused for the period trims to 0" begin + pool = MetalAdaptiveArrayPool{0, METAL_STORAGE}() + checkpoint!(pool); acquire!(pool, Int32, 1000); acquire!(pool, Int32, 1000); rewind!(pool) + pool.int32._ac_peak_n_active = 0 # new period, then NO Int32 use + @test length(pool.int32.vectors) == 2 + @atomic pool._trim_requested = true + AAP._run_auto_manage!(pool) + @test length(pool.int32.vectors) == 0 # unused type fully dropped + end + + @testset "no-op when _trim_requested is clear" begin + pool = MetalAdaptiveArrayPool{0, METAL_STORAGE}() + checkpoint!(pool); acquire!(pool, Float32, 100); acquire!(pool, Float32, 100); rewind!(pool) + pool.float32._ac_peak_n_active = 0 + AAP._run_auto_manage!(pool) # no flag set + @test length(pool.float32.vectors) == 2 # untouched + end + + # ── Overhead note ──────────────────────────────────────────────────────── + # The hot-path `max` writes one `Int` field and is byte-identical to the CPU claim + # path, whose zero-allocation is proven in test/test_auto_trim.jl. We do NOT re-assert + # `@allocated == 0` here: Metal `acquire!` carries inherent CPU-side kernel-launch + # overhead (~56 B/call; see test/metal/test_allocation.jl) that swamps the `max` and + # would make a strict-zero assertion test Metal's acquire overhead, not the `max`. + @testset "peak tracking is live through real acquire! (functional guard)" begin + pool = MetalAdaptiveArrayPool{0, METAL_STORAGE}() + checkpoint!(pool); acquire!(pool, Float32, 8); acquire!(pool, Float32, 8); rewind!(pool) + @test pool.float32._ac_peak_n_active >= 2 # the max is tracking the peak + end + + # ── Safety (R=1): auto-trim drops the slot ref WITHOUT a buffer swap ────── + # Unlike `compact!` (which allocates a smaller device buffer and swaps the slot's + # DataRef via `_update_metal_wrapper_data!`), `_trim_to!` only drops the slot + # reference. The captured MtlArray — and any escaped view sharing its DataRef — is + # left byte-for-byte untouched, so R=1 poison on a dropped slot survives. + @testset "R=1: auto-trim leaves the dropped slot's MtlArray DataRef intact (no swap)" begin + pool = MetalAdaptiveArrayPool{1, METAL_STORAGE}() # runtime checks ON + checkpoint!(pool); a = acquire!(pool, Float32, 100); fill!(a, 1.0f0); rewind!(pool) # released + tp = pool.float32 + @test length(tp.vectors) == 1 + v = tp.vectors[1] # the backing an escaped view would share + data_before = getfield(v, :data) # its DataRef (compact! would swap this) + pool.float32._ac_peak_n_active = 0 # unused this period → trim to 0 + @atomic pool._trim_requested = true + AAP._run_auto_manage!(pool) + @test length(tp.vectors) == 0 # slot reference dropped + @test getfield(v, :data) === data_before # buffer NOT swapped → escaped view intact + end + + # ── Cadence: the sweep now flags _trim_requested on a Metal pool ────────── + # Proves the `hasfield(typeof(p), :_trim_requested)` guard in the sweep (added in the + # CPU phase) now passes for Metal pools — i.e. the field is wired into this backend. + @testset "registry sweep flags _trim_requested on a registered Metal pool" begin + _clear_registry!() + AAP.disable_auto_manage!() + p = MetalAdaptiveArrayPool{0, METAL_STORAGE}() + AAP.register_auto_manage!(p) + @test _registry_len() == 1 + AAP.enable_auto_manage!(interval = 0.1, trim_interval = 0.1) # K=1: trim every sweep + AAP._auto_manage_sweep!(nothing) # one sweep body → trim cadence hits + @test (@atomic p._trim_requested) == true + AAP.disable_auto_manage!() + _clear_registry!() + end +end + +AAP.disable_auto_manage!() # leave the timer stopped for later Metal test files From 48a3d5e1c8ac23e13c1fd6455c43455ffa52fafa Mon Sep 17 00:00:00 2001 From: Min-Gu Yoo Date: Tue, 30 Jun 2026 13:40:04 -0700 Subject: [PATCH 04/15] feat(auto_manage): auto-trim CUDA parity Mirror the CPU/Metal auto-trim data layer onto the CUDA backend (commits 24586d9, ab9be38). The service layer (`_trim_to!`, `_auto_trim!`, `_run_auto_manage!`) is generic over the abstract pool supertypes and already worked for CUDA, so this is purely the two telemetry fields plus the claim-path `max`: - `_ac_peak_n_active::Int` on CuTypedPool: peak `n_active` since the last trim. One branchless `max` in both `_cuda_claim_slot!` methods, gated by the compile-time AUTO_MANAGE const -> DCE'd to nothing when off. The operands are host-side `Int` fields (NOT device arrays), so this is a CPU integer op, not a GPU scalar op. - `@atomic _trim_requested::Bool` on CuAdaptiveArrayPool (+ both the main ctor and the `_transfer_cuda_pool` migration ctor, which resets it like `_compact_requested`), parallel to `_compact_requested`. The base-module Timer sweep's `hasfield` guard now const-folds true for CUDA pools, so the sweep flags trim every `trim_interval`; the unchanged entry hook (fires on `_compact_requested`, set on the same sweep) lets the generic `_run_auto_manage!` service both at the `@with_pool :cuda` safepoint. Tests (test/cuda/test_auto_trim.jl): the CPU/Metal suite mirrored for CUDA -- peak tracking through `_cuda_claim_slot!`, `_trim_to!` truncation + regrow, auto-trim to the recent peak / to 0 for an unused type, no-op when unflagged, an S=1 no-buffer-swap guard (the dropped slot's CuArray DataRef is left intact, unlike compact!), and the sweep flagging `_trim_requested` on a registered CUDA pool. Validated locally without a GPU via extension precompile + struct/ctor-arity reflection (fieldcount 7 / 18); the device-level suite runs on the CUDA box. --- ext/AdaptiveArrayPoolsCUDAExt/acquire.jl | 5 + ext/AdaptiveArrayPoolsCUDAExt/types.jl | 21 +++- test/cuda/runtests.jl | 1 + test/cuda/test_auto_trim.jl | 153 +++++++++++++++++++++++ 4 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 test/cuda/test_auto_trim.jl diff --git a/ext/AdaptiveArrayPoolsCUDAExt/acquire.jl b/ext/AdaptiveArrayPoolsCUDAExt/acquire.jl index 0ed23880..12cf3b7d 100644 --- a/ext/AdaptiveArrayPoolsCUDAExt/acquire.jl +++ b/ext/AdaptiveArrayPoolsCUDAExt/acquire.jl @@ -94,6 +94,10 @@ CUDA.jl's resize! unnecessarily (especially after safety invalidation sets dims= @inline function _cuda_claim_slot!(tp::CuTypedPool{T}, total_len::Int) where {T} tp.n_active += 1 idx = tp.n_active + # Auto-trim working-set telemetry (parity with CPU `_claim_slot!`): track the peak + # concurrency since the last trim. Branchless `max` on host-side `Int` fields (NOT a GPU + # scalar op), gated by the compile-time const → DCE'd to nothing when AUTO_MANAGE is off. + AdaptiveArrayPools.AUTO_MANAGE && (tp._ac_peak_n_active = max(tp._ac_peak_n_active, idx)) if idx > length(tp.vectors) push!(tp.vectors, allocate_vector(tp, total_len)) push!(tp.slot_extents, total_len) # parallel to `vectors` (for compact!/_slot_used) @@ -119,6 +123,7 @@ the wrapper points to a different array's memory via `setfield!(:data)`. @inline function _cuda_claim_slot!(tp::CuTypedPool{T}) where {T} tp.n_active += 1 idx = tp.n_active + AdaptiveArrayPools.AUTO_MANAGE && (tp._ac_peak_n_active = max(tp._ac_peak_n_active, idx)) # working-set peak (DCE'd off) if idx > length(tp.vectors) push!(tp.vectors, CuVector{T}(undef, 0)) push!(tp.slot_extents, 0) # placeholder slot: wrapper points elsewhere (reshape!) diff --git a/ext/AdaptiveArrayPoolsCUDAExt/types.jl b/ext/AdaptiveArrayPoolsCUDAExt/types.jl index 6c2e45c5..9588ed97 100644 --- a/ext/AdaptiveArrayPoolsCUDAExt/types.jl +++ b/ext/AdaptiveArrayPoolsCUDAExt/types.jl @@ -45,6 +45,12 @@ mutable struct CuTypedPool{T} <: AbstractTypedPool{T, CuVector{T}} n_active::Int _checkpoint_n_active::Vector{Int} _checkpoint_depths::Vector{Int} + + # --- Auto-trim telemetry (parity with CPU TypedPool; see its docstring) --- + # Peak `n_active` since the last auto-trim — the recent working-set width. Written on the + # hot path (`_cuda_claim_slot!`, one `max`, gated by AUTO_MANAGE → DCE'd off); auto-trim + # reads it as the slot count to keep, then resets it to 0. Owner-only (non-atomic). + _ac_peak_n_active::Int end function CuTypedPool{T}() where {T} @@ -52,7 +58,8 @@ function CuTypedPool{T}() where {T} CuVector{T}[], # vectors Union{Nothing, Vector{Any}}[], # arr_wrappers (indexed by N) Int[], # slot_extents (parallel to vectors) - 0, [0], [0] # State (1-based sentinel) + 0, [0], [0], # State (1-based sentinel) + 0, # _ac_peak_n_active: no usage observed yet ) end @@ -126,10 +133,14 @@ mutable struct CuAdaptiveArrayPool{S} <: AbstractArrayPool _pending_return_site::String _borrow_log::Union{Nothing, IdDict{Any, String}} - # Auto-compact request flag (parity with CPU AdaptiveArrayPool): set by the base + # Auto-manage request flags (parity with CPU AdaptiveArrayPool): set by the base # module's background Timer sweep (a different thread), read + reset by the owner task # at the `@with_pool :cuda` entry safepoint. Atomic for the cross-thread handoff. + # `_compact_requested` is set every tick (→ compact!); `_trim_requested` every + # `trim_interval` (→ auto-trim the tail). The entry hook fires on `_compact_requested` + # (set on the same sweep), so the generic `_run_auto_manage!` services both at once. @atomic _compact_requested::Bool + @atomic _trim_requested::Bool end function CuAdaptiveArrayPool{S}() where {S} @@ -151,7 +162,8 @@ function CuAdaptiveArrayPool{S}() where {S} "", # _pending_callsite "", # _pending_return_site nothing, # _borrow_log: lazily created when S >= 1 - false # _compact_requested: no pending auto-manage request + false, # _compact_requested: no pending compact request + false # _trim_requested: no pending auto-trim request ) end @@ -213,7 +225,8 @@ function _transfer_cuda_pool(::Val{V}, old::CuAdaptiveArrayPool) where {V} "", # _pending_callsite: reset "", # _pending_return_site: reset V >= 1 ? IdDict{Any, String}() : nothing, # _borrow_log - false # _compact_requested: reset on migration + false, # _compact_requested: reset on migration + false # _trim_requested: reset on migration ) end diff --git a/test/cuda/runtests.jl b/test/cuda/runtests.jl index 9052d9b5..ae822708 100644 --- a/test/cuda/runtests.jl +++ b/test/cuda/runtests.jl @@ -52,5 +52,6 @@ else include("test_trim.jl") include("test_compact.jl") include("test_auto_manage.jl") + include("test_auto_trim.jl") end end diff --git a/test/cuda/test_auto_trim.jl b/test/cuda/test_auto_trim.jl new file mode 100644 index 00000000..6fed3a75 --- /dev/null +++ b/test/cuda/test_auto_trim.jl @@ -0,0 +1,153 @@ +# CUDA Auto-Trim Tests +# ===================== +# Parity with the CPU suite (test/test_auto_trim.jl) and the Metal mirror, for CUDA — the +# second action of the unified auto_manage engine. Design: docs/plans/DESIGN_auto_trim.md +# +# A `CuTypedPool` records the peak `n_active` reached since the last auto-trim in +# `_ac_peak_n_active` (one hot-path `max` in `_cuda_claim_slot!`, gated by AUTO_MANAGE). +# Every `trim_interval`, the base-module Timer sets the pool's `@atomic _trim_requested`; +# the owner services it at a `@with_pool :cuda` entry — trimming each type's slot tail +# down to that recent peak (a type unused for the period trims to 0), then resetting it. +# +# The service layer (`_trim_to!`, `_auto_trim!`, `_run_auto_manage!`) is generic over +# `AbstractTypedPool`/`AbstractArrayPool`, so this backend needs only the two data-layer +# fields + the claim-path `max`. Uses Float32 (GPU's primary type). + +import AdaptiveArrayPools as AAP + +AAP.disable_auto_manage!() # stop the __init__-started timer for deterministic tests + +@testset "CUDA auto_trim" begin + + # Scoped INSIDE the testset (local scope) so the helper doesn't define a top-level + # `Main` method colliding with the identically-named helper in the CPU/Metal auto-trim + # files when the full suite loads several backends together. + _clear_registry!() = lock(() -> empty!(AAP._AUTO_MANAGE_REGISTRY), AAP._AUTO_MANAGE_LOCK) + _registry_len() = lock(() -> length(AAP._AUTO_MANAGE_REGISTRY), AAP._AUTO_MANAGE_LOCK) + + # ── Data layer ─────────────────────────────────────────────────────────── + @testset "CuTypedPool._ac_peak_n_active (default 0) + pool._trim_requested (default false)" begin + pool = CuAdaptiveArrayPool{0}() + @test pool.float32._ac_peak_n_active == 0 + @test (@atomic pool._trim_requested) == false + @atomic pool._trim_requested = true + @test (@atomic pool._trim_requested) == true + end + + @testset "_cuda_claim_slot! records the peak n_active in _ac_peak_n_active" begin + pool = CuAdaptiveArrayPool{0}() + checkpoint!(pool) + acquire!(pool, Float32, 4); acquire!(pool, Float32, 4); acquire!(pool, Float32, 4) # n_active → 3 + rewind!(pool) + @test pool.float32._ac_peak_n_active == 3 # peak captured (not reset by rewind) + # a narrower second period after a manual reset stays at the new peak + pool.float32._ac_peak_n_active = 0 + checkpoint!(pool); acquire!(pool, Float32, 4); rewind!(pool) + @test pool.float32._ac_peak_n_active == 1 + end + + # ── _trim_to! primitive (generic, dispatches on CuTypedPool) ────────────── + @testset "_trim_to!(tp, keep) truncates vectors/wrappers/slot_extents; slot regrows" begin + pool = CuAdaptiveArrayPool{0}() + checkpoint!(pool) + acquire!(pool, Float32, 100); acquire!(pool, Float32, 100); acquire!(pool, Float32, 100) + rewind!(pool) + tp = pool.float32 + @test length(tp.vectors) == 3 && length(tp.slot_extents) == 3 + + AAP._trim_to!(tp, 1) + @test length(tp.vectors) == 1 + @test length(tp.slot_extents) == 1 + + checkpoint!(pool); a = acquire!(pool, Float32, 60); fill!(a, 7.0f0); rewind!(pool) # regrow slot 1 + @test length(a) == 60 && all(==(7.0f0), Array(a)) + AAP._trim_to!(tp, 0) # trim everything + @test length(tp.vectors) == 0 + end + + # ── Service: auto-trim to the recent peak ──────────────────────────────── + @testset "_run_auto_manage! trims each type's tail to its recent peak, then resets" begin + pool = CuAdaptiveArrayPool{0}() + checkpoint!(pool) # wide: 3 concurrent + acquire!(pool, Float32, 100); acquire!(pool, Float32, 100); acquire!(pool, Float32, 100) + rewind!(pool) + pool.float32._ac_peak_n_active = 0 # start a fresh observation period + checkpoint!(pool); acquire!(pool, Float32, 100); rewind!(pool) # narrowed: peak = 1 + @test pool.float32._ac_peak_n_active == 1 + @test length(pool.float32.vectors) == 3 # tail still retained + + @atomic pool._trim_requested = true + AAP._run_auto_manage!(pool) + + @test length(pool.float32.vectors) == 1 # trimmed to the recent peak + @test pool.float32._ac_peak_n_active == 0 # reset for the next period + @test (@atomic pool._trim_requested) == false # flag consumed + end + + @testset "a type unused for the period trims to 0" begin + pool = CuAdaptiveArrayPool{0}() + checkpoint!(pool); acquire!(pool, Int32, 1000); acquire!(pool, Int32, 1000); rewind!(pool) + pool.int32._ac_peak_n_active = 0 # new period, then NO Int32 use + @test length(pool.int32.vectors) == 2 + @atomic pool._trim_requested = true + AAP._run_auto_manage!(pool) + @test length(pool.int32.vectors) == 0 # unused type fully dropped + end + + @testset "no-op when _trim_requested is clear" begin + pool = CuAdaptiveArrayPool{0}() + checkpoint!(pool); acquire!(pool, Float32, 100); acquire!(pool, Float32, 100); rewind!(pool) + pool.float32._ac_peak_n_active = 0 + AAP._run_auto_manage!(pool) # no flag set + @test length(pool.float32.vectors) == 2 # untouched + end + + # ── Overhead note ──────────────────────────────────────────────────────── + # The hot-path `max` writes one host-side `Int` field and is byte-identical to the CPU + # claim path, whose zero-allocation is proven in test/test_auto_trim.jl. It is NOT a GPU + # scalar op (the operands are host `Int`s, not device arrays). We do NOT re-assert + # `@allocated == 0` here: CUDA `acquire!` carries inherent CPU-side overhead that swamps + # the `max`. Functional guard only. + @testset "peak tracking is live through real acquire! (functional guard)" begin + pool = CuAdaptiveArrayPool{0}() + checkpoint!(pool); acquire!(pool, Float32, 8); acquire!(pool, Float32, 8); rewind!(pool) + @test pool.float32._ac_peak_n_active >= 2 # the max is tracking the peak + end + + # ── Safety (S=1): auto-trim drops the slot ref WITHOUT a buffer swap ────── + # Unlike `compact!` (which allocates a smaller device buffer and swaps the slot's + # DataRef via `_update_cuda_wrapper_data!`), `_trim_to!` only drops the slot reference. + # The captured CuArray — and any escaped view sharing its DataRef — is left byte-for-byte + # untouched, so S=1 poison on a dropped slot survives. + @testset "S=1: auto-trim leaves the dropped slot's CuArray DataRef intact (no swap)" begin + pool = CuAdaptiveArrayPool{1}() # runtime checks ON + checkpoint!(pool); a = acquire!(pool, Float32, 100); fill!(a, 1.0f0); rewind!(pool) # released + tp = pool.float32 + @test length(tp.vectors) == 1 + v = tp.vectors[1] # the backing an escaped view would share + data_before = getfield(v, :data) # its DataRef (compact! would swap this) + pool.float32._ac_peak_n_active = 0 # unused this period → trim to 0 + @atomic pool._trim_requested = true + AAP._run_auto_manage!(pool) + @test length(tp.vectors) == 0 # slot reference dropped + @test getfield(v, :data) === data_before # buffer NOT swapped → escaped view intact + end + + # ── Cadence: the sweep now flags _trim_requested on a CUDA pool ─────────── + # Proves the `hasfield(typeof(p), :_trim_requested)` guard in the sweep (added in the + # CPU phase) now passes for CUDA pools — i.e. the field is wired into this backend. + @testset "registry sweep flags _trim_requested on a registered CUDA pool" begin + _clear_registry!() + AAP.disable_auto_manage!() + p = CuAdaptiveArrayPool{0}() + AAP.register_auto_manage!(p) + @test _registry_len() == 1 + AAP.enable_auto_manage!(interval = 0.1, trim_interval = 0.1) # K=1: trim every sweep + AAP._auto_manage_sweep!(nothing) # one sweep body → trim cadence hits + @test (@atomic p._trim_requested) == true + AAP.disable_auto_manage!() + _clear_registry!() + end +end + +AAP.disable_auto_manage!() # leave the timer stopped for later CUDA test files From 006552cbbdeff11454e6a57ff45c43cafd96c72c Mon Sep 17 00:00:00 2001 From: Min-Gu Yoo Date: Tue, 30 Jun 2026 13:57:11 -0700 Subject: [PATCH 05/15] test(auto_manage): end-to-end integrity + real-time cadence Close the gap between the unit suites (which test the mechanism in isolation -- set a flag, call the service; the depth-1 safepoint no-op) and the safety guarantee that actually matters: the real background Timer firing repeatedly mid-computation must never corrupt a result. test/test_auto_integration.jl: - A. Differential integrity: a deterministic, pool-heavy simulation (varying array size -> compact bloat; varying concurrency width -> trim tail; nested depth-2 scope) run with auto-manage OFF then ON (real Timer at 0.01s, ~40 sweeps over the run). Asserts the two result vectors are BIT-IDENTICAL -- auto-compact/auto-trim reclaim memory between scopes, never change a computed value. Plus self-heal-after-reclaim correctness, and measurable reclamation (slot tail + byte bloat both shrink). - B. Real-time cadence: over real elapsed seconds, asserts the Timer flags compaction every tick and auto-trim only every K-th tick (K = round(trim_interval/interval)), observed without servicing so the raw cadence is visible. Timing margins match/exceed the existing sleep-based timer tests; verified stable across repeated local runs (9/9 testset, ~5s). CPU only for now; Metal/CUDA mirrors to follow. --- test/runtests.jl | 1 + test/test_auto_integration.jl | 130 ++++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 test/test_auto_integration.jl diff --git a/test/runtests.jl b/test/runtests.jl index c2227c73..c91182ec 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -30,6 +30,7 @@ else include("test_compact.jl") include("test_auto_manage.jl") include("test_auto_trim.jl") + include("test_auto_integration.jl") include("test_multidimensional.jl") include("test_macros.jl") include("test_task_local_pool.jl") diff --git a/test/test_auto_integration.jl b/test/test_auto_integration.jl new file mode 100644 index 00000000..b0a9603e --- /dev/null +++ b/test/test_auto_integration.jl @@ -0,0 +1,130 @@ +using Test +using AdaptiveArrayPools +using AdaptiveArrayPools: checkpoint!, rewind! +import AdaptiveArrayPools as AAP + +# ============================================================================== +# Auto-manage END-TO-END integration + real-time tests. +# +# The unit suites (test_auto_manage.jl / test_auto_trim.jl) cover the mechanism in +# isolation — set a flag, call the service; the depth-1 safepoint no-op. These tests +# close the remaining gap by running the REAL background Timer against a real, +# pool-heavy computation: +# +# (A) DIFFERENTIAL INTEGRITY — the safety guarantee. The same computation, run with +# auto-manage OFF and then ON (real Timer firing repeatedly mid-loop), must +# produce BIT-IDENTICAL results. Auto-compact/auto-trim reclaim memory between +# scopes; they must never change a single computed value. If they ever did, this +# test fails — which is the "never corrupt a simulation" contract, asserted. +# +# (B) REAL-TIME CADENCE — over real elapsed seconds, the Timer flags compaction every +# tick and auto-trim every K-th tick (K = round(trim_interval/interval)). +# +# AUTO_MANAGE defaults on; we stop the __init__ timer up front for determinism and +# re-enable it explicitly (short intervals) inside the testsets that need it. +# ============================================================================== + +AAP.disable_auto_manage!() + +@testset "auto_manage integration + real-time" begin + + _cap(v) = length(getfield(v, :ref).mem) # CPU backing capacity (elements) + _clear_registry!() = lock(() -> empty!(AAP._AUTO_MANAGE_REGISTRY), AAP._AUTO_MANAGE_LOCK) + + # Sized (~100 µs/step) so the ON run spans ~0.4 s wall-time — at the 0.01 s Timer + # interval that is ~40 sweeps (≈20 auto-trims) firing DURING the computation. + N_STEPS = 4000 + + # A deterministic, pool-heavy "simulation". Each step varies BOTH the array size + # (periodic 1.6 MB slot → auto-compact bloat) and the concurrency width 1..5 (slot-count + # peak → auto-trim tail), across a nested depth-2 scope. Every value is freshly computed + # from arithmetic only, so the output must be identical whether or not auto-manage + # reclaims memory between steps. Returns the per-step reduction vector. + function run_sim!(n_steps) + out = Vector{Float64}(undef, n_steps) + for it in 1:n_steps + width = (it % 5) + 1 # 1..5 concurrent arrays + big = (it % 11 == 0) ? 200_000 : 256 # periodic compactable bloat + @with_pool p begin # depth-1 entry → auto-manage safepoint + acc = 0.0 + @with_pool q begin # depth-2: hold `width` arrays at once + arrs = Vector{Vector{Float64}}(undef, width) + for k in 1:width + m = (k == 1) ? big : (64 + 7k) + a = acquire!(q, Float64, m) + @inbounds for i in 1:m + a[i] = sin(0.01 * (it + k)) + (i % 13) + end + arrs[k] = a + end + @inbounds for k in 1:width # cross-array reduction (would break on aliasing) + acc += sum(arrs[k]) - first(arrs[k]) * k + end + end + @inbounds out[it] = acc + it + end + end + return out + end + + # ── A. Differential integrity: auto-manage never changes results ───────── + @testset "A. real Timer firing mid-computation produces bit-identical results" begin + AAP.disable_auto_manage!(); _clear_registry!() + pool = get_task_local_pool() + + # Reference: auto-manage OFF — pool accumulates to the peak, nothing reclaimed. + empty!(pool) + ref = run_sim!(N_STEPS) + slots_off = length(pool.float64.vectors) # retained peak width (no trim ran) + big_off = maximum(_cap, pool.float64.vectors) # bloated big slot (no compact ran) + @test slots_off >= 5 # the sim really reached width-5… + @test big_off >= 200_000 # …and really created 1.6 MB bloat + + # Test: auto-manage ON with the REAL Timer at a short interval — it fires many times + # during the ~N_STEPS loop (interval 0.01 s ⇒ tens of sweeps over the run's wall-time). + empty!(pool) + AAP.register_auto_manage!(pool) + AAP.enable_auto_manage!(interval = 0.01, trim_interval = 0.02) + test = run_sim!(N_STEPS) + AAP.disable_auto_manage!() + + @test test == ref # ★ SAFETY: identical values, no corruption + + # Non-vacuity: the reclamation path actually shrinks the pool and the next acquire + # self-heals correctly. Reset the recent peak so the next auto-trim drops the whole + # tail deterministically, then service one flagged scope entry. + pool.float64._ac_peak_n_active = 0 + @atomic pool._trim_requested = true + @atomic pool._compact_requested = true + @with_pool p begin + v = acquire!(p, Float64, 8); v .= 3.0 + @test all(==(3.0), v) # self-heal after reclamation is correct + end + @test length(pool.float64.vectors) < slots_off # ★ auto-trim reclaimed the slot tail + @test maximum(_cap, pool.float64.vectors) < big_off # ★ auto-compact reclaimed the byte bloat + + empty!(pool); _clear_registry!() + end + + # ── B. Real-time cadence over real elapsed seconds ─────────────────────── + # Observe the raw flag cadence WITHOUT entering @with_pool (so nothing services/clears + # the flags): compact is flagged every tick, auto-trim only every K-th tick. + @testset "B. over real seconds: compact every tick, trim every K-th tick" begin + AAP.disable_auto_manage!(); _clear_registry!() + pool = AdaptiveArrayPool{0}() + AAP.register_auto_manage!(pool) + # interval 0.2 s, trim every 0.6 s ⇒ K = 3 ticks. Timer first fires at t = interval. + AAP.enable_auto_manage!(interval = 0.2, trim_interval = 0.6) + + sleep(0.35) # ~1 tick elapsed (tick @ 0.2 s) + @test (@atomic pool._compact_requested) == true # compact flagged after 1 tick + @test (@atomic pool._trim_requested) == false # trim NOT yet (needs 3 ticks) + + sleep(0.75) # ~total 1.1 s ⇒ ≥3 ticks (trim @ tick 3 = 0.6 s) + @test (@atomic pool._trim_requested) == true # trim flagged after K ticks + + AAP.disable_auto_manage!(); _clear_registry!() + end +end + +AAP.disable_auto_manage!() # leave the timer stopped for later test files From dda67b043d7168e001873cbdf5c8f2a47734a4cf Mon Sep 17 00:00:00 2001 From: Min-Gu Yoo Date: Tue, 30 Jun 2026 14:01:30 -0700 Subject: [PATCH 06/15] test(auto_manage): GPU integrity + real-time cadence (Metal + CUDA) Mirror the CPU end-to-end integration suite (006552c) onto Metal and CUDA. Same two guarantees -- (A) the real Timer firing repeatedly mid-computation produces BIT-IDENTICAL results vs auto-manage off, and (B) the compact-every-tick / trim-every-K-th cadence over real elapsed seconds -- adapted for GPU: - No element-wise writes (that is GPU scalar indexing): each slot is fill!ed with a small integer-valued Float32 kept under 2^24 * value, so the on-device `sum` is an exact integer independent of the parallel reduction order (bit-reproducible run-to-run, verified), while fill! over the whole buffer keeps the test sensitive to any aliasing/corruption. Metal: full suite green locally (integration testset 9/9, 4.1s). CUDA: identical mirror, parse-checked locally; the device suite runs on the GPU box. --- test/cuda/runtests.jl | 1 + test/cuda/test_auto_integration.jl | 109 +++++++++++++++++++++++++++ test/metal/runtests.jl | 1 + test/metal/test_auto_integration.jl | 110 ++++++++++++++++++++++++++++ 4 files changed, 221 insertions(+) create mode 100644 test/cuda/test_auto_integration.jl create mode 100644 test/metal/test_auto_integration.jl diff --git a/test/cuda/runtests.jl b/test/cuda/runtests.jl index ae822708..17dc64d6 100644 --- a/test/cuda/runtests.jl +++ b/test/cuda/runtests.jl @@ -53,5 +53,6 @@ else include("test_compact.jl") include("test_auto_manage.jl") include("test_auto_trim.jl") + include("test_auto_integration.jl") end end diff --git a/test/cuda/test_auto_integration.jl b/test/cuda/test_auto_integration.jl new file mode 100644 index 00000000..44447cad --- /dev/null +++ b/test/cuda/test_auto_integration.jl @@ -0,0 +1,109 @@ +# CUDA Auto-manage END-TO-END integration + real-time tests. +# ======================================================== +# Parity with the CPU suite (test/test_auto_integration.jl) and the Metal mirror, for CUDA. +# +# (A) DIFFERENTIAL INTEGRITY — the same pool-heavy GPU computation, run with auto-manage +# OFF then ON (real Timer firing repeatedly mid-loop), must produce BIT-IDENTICAL +# results. Auto-compact/auto-trim reclaim device buffers between scopes; they must +# never change a computed value. +# (B) REAL-TIME CADENCE — over real elapsed seconds, compact is flagged every tick and +# auto-trim every K-th tick, on a CUDA pool. +# +# GPU adaptation vs. the CPU sim: no element-wise writes (that is GPU scalar indexing). +# Each slot is `fill!`ed with a small INTEGER-valued Float32 and kept under 2^24 elements*value +# so `sum` is an exact integer regardless of the parallel reduction order — bit-reproducible +# run-to-run, while `fill!` writing the whole buffer keeps the test sensitive to any aliasing. + +import AdaptiveArrayPools as AAP + +AAP.disable_auto_manage!() + +@testset "CUDA auto_manage integration + real-time" begin + + _ccap(v) = Int(getfield(v, :maxsize) ÷ sizeof(eltype(v))) # CUDA device capacity (elements) + _clear_registry!() = lock(() -> empty!(AAP._AUTO_MANAGE_REGISTRY), AAP._AUTO_MANAGE_LOCK) + + # GPU kernel-launch bound; spans enough wall-time for many 0.01 s ticks. + N_STEPS = 300 + + # Deterministic GPU "simulation": varies array size (periodic 1.6 MB slot → compact bloat) + # and concurrency width 1..5 (slot-count peak → auto-trim tail) across a nested depth-2 + # scope. Fills with integer-valued Float32 (exact sums) and reduces on-device. + function run_sim_cuda!(n_steps) + out = Vector{Float64}(undef, n_steps) + for it in 1:n_steps + width = (it % 5) + 1 + big = (it % 11 == 0) ? 200_000 : 256 + @with_pool :cuda p begin + acc = 0.0 + @with_pool :cuda q begin + arrs = Vector{Any}(undef, width) + for k in 1:width + m = (k == 1) ? big : (64 + 7k) + a = acquire!(q, Float32, m) + fill!(a, Float32((it + k) % 7 + 1)) # integer fill → exact sum + arrs[k] = a + end + for k in 1:width + acc += Float64(sum(arrs[k])) # on-device reduction, exact + end + end + @inbounds out[it] = acc + it + end + end + return out + end + + # ── A. Differential integrity ──────────────────────────────────────────── + @testset "A. real Timer firing mid-computation produces bit-identical results" begin + AAP.disable_auto_manage!(); _clear_registry!() + pool = get_task_local_cuda_pool() + + empty!(pool) + ref = run_sim_cuda!(N_STEPS) + slots_off = length(pool.float32.vectors) + big_off = maximum(_ccap, pool.float32.vectors) + @test slots_off >= 5 + @test big_off >= 200_000 + + empty!(pool) + AAP.register_auto_manage!(pool) + AAP.enable_auto_manage!(interval = 0.01, trim_interval = 0.02) + test = run_sim_cuda!(N_STEPS) + AAP.disable_auto_manage!() + + @test test == ref # ★ SAFETY: identical values, no corruption + + # Deterministic reclamation + self-heal after the run. + pool.float32._ac_peak_n_active = 0 + @atomic pool._trim_requested = true + @atomic pool._compact_requested = true + @with_pool :cuda p begin + v = acquire!(p, Float32, 8); fill!(v, 3.0f0) + @test all(==(3.0f0), Array(v)) # self-heal after reclamation correct + end + @test length(pool.float32.vectors) < slots_off # ★ auto-trim reclaimed the tail + @test maximum(_ccap, pool.float32.vectors) < big_off # ★ auto-compact reclaimed the bloat + + empty!(pool); _clear_registry!() + end + + # ── B. Real-time cadence over real elapsed seconds (CUDA pool) ──────────── + @testset "B. over real seconds: compact every tick, trim every K-th tick" begin + AAP.disable_auto_manage!(); _clear_registry!() + pool = CuAdaptiveArrayPool{0}() + AAP.register_auto_manage!(pool) + AAP.enable_auto_manage!(interval = 0.2, trim_interval = 0.6) # K = 3 + + sleep(0.35) # ~1 tick + @test (@atomic pool._compact_requested) == true + @test (@atomic pool._trim_requested) == false + + sleep(0.75) # ~total 1.1 s ⇒ ≥3 ticks + @test (@atomic pool._trim_requested) == true + + AAP.disable_auto_manage!(); _clear_registry!() + end +end + +AAP.disable_auto_manage!() diff --git a/test/metal/runtests.jl b/test/metal/runtests.jl index 2ff14de5..3ee5fbe6 100644 --- a/test/metal/runtests.jl +++ b/test/metal/runtests.jl @@ -55,5 +55,6 @@ else include("test_compact.jl") include("test_auto_manage.jl") include("test_auto_trim.jl") + include("test_auto_integration.jl") end end diff --git a/test/metal/test_auto_integration.jl b/test/metal/test_auto_integration.jl new file mode 100644 index 00000000..02e714fe --- /dev/null +++ b/test/metal/test_auto_integration.jl @@ -0,0 +1,110 @@ +# Metal Auto-manage END-TO-END integration + real-time tests. +# ========================================================= +# Parity with the CPU suite (test/test_auto_integration.jl) for the Metal backend. +# +# (A) DIFFERENTIAL INTEGRITY — the same pool-heavy GPU computation, run with auto-manage +# OFF then ON (real Timer firing repeatedly mid-loop), must produce BIT-IDENTICAL +# results. Auto-compact/auto-trim reclaim device buffers between scopes; they must +# never change a computed value. +# (B) REAL-TIME CADENCE — over real elapsed seconds, compact is flagged every tick and +# auto-trim every K-th tick, on a Metal pool. +# +# GPU adaptation vs. the CPU sim: no element-wise writes (that is GPU scalar indexing). +# Each slot is `fill!`ed with a small INTEGER-valued Float32 and kept under 2^24 elements*value +# so `sum` is an exact integer regardless of the parallel reduction order — bit-reproducible +# run-to-run, while `fill!` writing the whole buffer keeps the test sensitive to any aliasing. + +import AdaptiveArrayPools as AAP + +AAP.disable_auto_manage!() + +@testset "Metal auto_manage integration + real-time" begin + + _mcap(v) = Int(getfield(v, :maxsize) ÷ sizeof(eltype(v))) # Metal device capacity (elements) + _clear_registry!() = lock(() -> empty!(AAP._AUTO_MANAGE_REGISTRY), AAP._AUTO_MANAGE_LOCK) + + # GPU kernel-launch bound (~0.5 s for 300 steps); spans enough wall-time for many + # 0.01 s ticks. (GPU `sum` over integer fills is bit-reproducible — verified.) + N_STEPS = 300 + + # Deterministic GPU "simulation": varies array size (periodic 1.6 MB slot → compact bloat) + # and concurrency width 1..5 (slot-count peak → auto-trim tail) across a nested depth-2 + # scope. Fills with integer-valued Float32 (exact sums) and reduces on-device. + function run_sim_metal!(n_steps) + out = Vector{Float64}(undef, n_steps) + for it in 1:n_steps + width = (it % 5) + 1 + big = (it % 11 == 0) ? 200_000 : 256 + @with_pool :metal p begin + acc = 0.0 + @with_pool :metal q begin + arrs = Vector{Any}(undef, width) + for k in 1:width + m = (k == 1) ? big : (64 + 7k) + a = acquire!(q, Float32, m) + fill!(a, Float32((it + k) % 7 + 1)) # integer fill → exact sum + arrs[k] = a + end + for k in 1:width + acc += Float64(sum(arrs[k])) # on-device reduction, exact + end + end + @inbounds out[it] = acc + it + end + end + return out + end + + # ── A. Differential integrity ──────────────────────────────────────────── + @testset "A. real Timer firing mid-computation produces bit-identical results" begin + AAP.disable_auto_manage!(); _clear_registry!() + pool = get_task_local_metal_pool() + + empty!(pool) + ref = run_sim_metal!(N_STEPS) + slots_off = length(pool.float32.vectors) + big_off = maximum(_mcap, pool.float32.vectors) + @test slots_off >= 5 + @test big_off >= 200_000 + + empty!(pool) + AAP.register_auto_manage!(pool) + AAP.enable_auto_manage!(interval = 0.01, trim_interval = 0.02) + test = run_sim_metal!(N_STEPS) + AAP.disable_auto_manage!() + + @test test == ref # ★ SAFETY: identical values, no corruption + + # Deterministic reclamation + self-heal after the run. + pool.float32._ac_peak_n_active = 0 + @atomic pool._trim_requested = true + @atomic pool._compact_requested = true + @with_pool :metal p begin + v = acquire!(p, Float32, 8); fill!(v, 3.0f0) + @test all(==(3.0f0), Array(v)) # self-heal after reclamation correct + end + @test length(pool.float32.vectors) < slots_off # ★ auto-trim reclaimed the tail + @test maximum(_mcap, pool.float32.vectors) < big_off # ★ auto-compact reclaimed the bloat + + empty!(pool); _clear_registry!() + end + + # ── B. Real-time cadence over real elapsed seconds (Metal pool) ─────────── + @testset "B. over real seconds: compact every tick, trim every K-th tick" begin + AAP.disable_auto_manage!(); _clear_registry!() + pool = MetalAdaptiveArrayPool{0, METAL_STORAGE}() + AAP.register_auto_manage!(pool) + AAP.enable_auto_manage!(interval = 0.2, trim_interval = 0.6) # K = 3 + + sleep(0.35) # ~1 tick + @test (@atomic pool._compact_requested) == true + @test (@atomic pool._trim_requested) == false + + sleep(0.75) # ~total 1.1 s ⇒ ≥3 ticks + @test (@atomic pool._trim_requested) == true + + AAP.disable_auto_manage!(); _clear_registry!() + end +end + +AAP.disable_auto_manage!() From 693cc12f1aee4161c50bbdee091b27a809f78807 Mon Sep 17 00:00:00 2001 From: Min-Gu Yoo Date: Tue, 30 Jun 2026 14:20:15 -0700 Subject: [PATCH 07/15] =?UTF-8?q?refactor:=20rename=20=5Fac=5Fpeak=5Fn=5Fa?= =?UTF-8?q?ctive=20=E2=86=92=20=5Fam=5Fpeak=5Fn=5Factive=20(auto=5Fmanage?= =?UTF-8?q?=20namespace)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `_ac_` prefix was a leftover from the pre-rename `auto_compact` era; the field is auto-manage telemetry (consumed by auto-trim), so `_am_` matches AUTO_MANAGE and the `auto_manage` umbrella naming used everywhere else. Pure mechanical rename of the private field across CPU + Metal + CUDA + tests — no public API, branch unreleased. CPU auto suites green; Metal full suite validating. --- ext/AdaptiveArrayPoolsCUDAExt/acquire.jl | 4 ++-- ext/AdaptiveArrayPoolsCUDAExt/types.jl | 4 ++-- ext/AdaptiveArrayPoolsMetalExt/acquire.jl | 4 ++-- ext/AdaptiveArrayPoolsMetalExt/types.jl | 4 ++-- src/acquire.jl | 4 ++-- src/auto_manage.jl | 10 ++++---- src/bitarray.jl | 2 +- src/types.jl | 8 +++---- test/cuda/test_auto_integration.jl | 2 +- test/cuda/test_auto_trim.jl | 28 +++++++++++------------ test/metal/test_auto_integration.jl | 2 +- test/metal/test_auto_trim.jl | 28 +++++++++++------------ test/test_auto_integration.jl | 2 +- test/test_auto_trim.jl | 28 +++++++++++------------ 14 files changed, 65 insertions(+), 65 deletions(-) diff --git a/ext/AdaptiveArrayPoolsCUDAExt/acquire.jl b/ext/AdaptiveArrayPoolsCUDAExt/acquire.jl index 12cf3b7d..85108e2c 100644 --- a/ext/AdaptiveArrayPoolsCUDAExt/acquire.jl +++ b/ext/AdaptiveArrayPoolsCUDAExt/acquire.jl @@ -97,7 +97,7 @@ CUDA.jl's resize! unnecessarily (especially after safety invalidation sets dims= # Auto-trim working-set telemetry (parity with CPU `_claim_slot!`): track the peak # concurrency since the last trim. Branchless `max` on host-side `Int` fields (NOT a GPU # scalar op), gated by the compile-time const → DCE'd to nothing when AUTO_MANAGE is off. - AdaptiveArrayPools.AUTO_MANAGE && (tp._ac_peak_n_active = max(tp._ac_peak_n_active, idx)) + AdaptiveArrayPools.AUTO_MANAGE && (tp._am_peak_n_active = max(tp._am_peak_n_active, idx)) if idx > length(tp.vectors) push!(tp.vectors, allocate_vector(tp, total_len)) push!(tp.slot_extents, total_len) # parallel to `vectors` (for compact!/_slot_used) @@ -123,7 +123,7 @@ the wrapper points to a different array's memory via `setfield!(:data)`. @inline function _cuda_claim_slot!(tp::CuTypedPool{T}) where {T} tp.n_active += 1 idx = tp.n_active - AdaptiveArrayPools.AUTO_MANAGE && (tp._ac_peak_n_active = max(tp._ac_peak_n_active, idx)) # working-set peak (DCE'd off) + AdaptiveArrayPools.AUTO_MANAGE && (tp._am_peak_n_active = max(tp._am_peak_n_active, idx)) # working-set peak (DCE'd off) if idx > length(tp.vectors) push!(tp.vectors, CuVector{T}(undef, 0)) push!(tp.slot_extents, 0) # placeholder slot: wrapper points elsewhere (reshape!) diff --git a/ext/AdaptiveArrayPoolsCUDAExt/types.jl b/ext/AdaptiveArrayPoolsCUDAExt/types.jl index 9588ed97..e885b7ea 100644 --- a/ext/AdaptiveArrayPoolsCUDAExt/types.jl +++ b/ext/AdaptiveArrayPoolsCUDAExt/types.jl @@ -50,7 +50,7 @@ mutable struct CuTypedPool{T} <: AbstractTypedPool{T, CuVector{T}} # Peak `n_active` since the last auto-trim — the recent working-set width. Written on the # hot path (`_cuda_claim_slot!`, one `max`, gated by AUTO_MANAGE → DCE'd off); auto-trim # reads it as the slot count to keep, then resets it to 0. Owner-only (non-atomic). - _ac_peak_n_active::Int + _am_peak_n_active::Int end function CuTypedPool{T}() where {T} @@ -59,7 +59,7 @@ function CuTypedPool{T}() where {T} Union{Nothing, Vector{Any}}[], # arr_wrappers (indexed by N) Int[], # slot_extents (parallel to vectors) 0, [0], [0], # State (1-based sentinel) - 0, # _ac_peak_n_active: no usage observed yet + 0, # _am_peak_n_active: no usage observed yet ) end diff --git a/ext/AdaptiveArrayPoolsMetalExt/acquire.jl b/ext/AdaptiveArrayPoolsMetalExt/acquire.jl index 952ba695..b4f12d6b 100644 --- a/ext/AdaptiveArrayPoolsMetalExt/acquire.jl +++ b/ext/AdaptiveArrayPoolsMetalExt/acquire.jl @@ -81,7 +81,7 @@ Metal.jl's resize! unnecessarily (especially after safety invalidation sets dims # Auto-trim working-set telemetry (parity with CPU `_claim_slot!`): track the peak # concurrency since the last trim. Branchless `max`, gated by the compile-time const → # DCE'd to nothing when AUTO_MANAGE is off (one `Int` field write when on). - AdaptiveArrayPools.AUTO_MANAGE && (tp._ac_peak_n_active = max(tp._ac_peak_n_active, idx)) + AdaptiveArrayPools.AUTO_MANAGE && (tp._am_peak_n_active = max(tp._am_peak_n_active, idx)) if idx > length(tp.vectors) push!(tp.vectors, allocate_vector(tp, total_len)) push!(tp.slot_extents, total_len) # parallel to `vectors` (for compact!/_slot_used) @@ -103,7 +103,7 @@ the wrapper points to a different array's memory via `setfield!(:data)`. @inline function _metal_claim_slot!(tp::MetalTypedPool{T, S}) where {T, S} tp.n_active += 1 idx = tp.n_active - AdaptiveArrayPools.AUTO_MANAGE && (tp._ac_peak_n_active = max(tp._ac_peak_n_active, idx)) # working-set peak (DCE'd off) + AdaptiveArrayPools.AUTO_MANAGE && (tp._am_peak_n_active = max(tp._am_peak_n_active, idx)) # working-set peak (DCE'd off) if idx > length(tp.vectors) push!(tp.vectors, MtlArray{T, 1, S}(undef, 0)) push!(tp.slot_extents, 0) # placeholder slot: wrapper points elsewhere (reshape!) diff --git a/ext/AdaptiveArrayPoolsMetalExt/types.jl b/ext/AdaptiveArrayPoolsMetalExt/types.jl index 9ccf10e5..bc09c3c0 100644 --- a/ext/AdaptiveArrayPoolsMetalExt/types.jl +++ b/ext/AdaptiveArrayPoolsMetalExt/types.jl @@ -48,7 +48,7 @@ mutable struct MetalTypedPool{T, S} <: AbstractTypedPool{T, MtlArray{T, 1, S}} # Peak `n_active` since the last auto-trim — the recent working-set width. Written on the # hot path (`_metal_claim_slot!`, one `max`, gated by AUTO_MANAGE → DCE'd off); auto-trim # reads it as the slot count to keep, then resets it to 0. Owner-only (non-atomic). - _ac_peak_n_active::Int + _am_peak_n_active::Int end function MetalTypedPool{T, S}() where {T, S} @@ -57,7 +57,7 @@ function MetalTypedPool{T, S}() where {T, S} Union{Nothing, Vector{Any}}[], # arr_wrappers (indexed by N) Int[], # slot_extents (parallel to vectors) 0, [0], [0], # State (1-based sentinel) - 0, # _ac_peak_n_active: no usage observed yet + 0, # _am_peak_n_active: no usage observed yet ) end diff --git a/src/acquire.jl b/src/acquire.jl index 27a4bbf0..8fd9ecd8 100644 --- a/src/acquire.jl +++ b/src/acquire.jl @@ -68,7 +68,7 @@ Returns the slot index. This is the shared primitive for all acquisition paths # Auto-trim working-set telemetry: track the peak concurrency since the last trim. # Branchless `max` on the `n_active` bump; gated by the compile-time const → DCE'd to # nothing when AUTO_MANAGE is off (measured ~0.15 ns/acquire, zero-alloc, when on). - AUTO_MANAGE && (tp._ac_peak_n_active = max(tp._ac_peak_n_active, idx)) + AUTO_MANAGE && (tp._am_peak_n_active = max(tp._am_peak_n_active, idx)) if idx > length(tp.vectors) push!(tp.vectors, allocate_vector(tp, n)) push!(tp.slot_extents, n) # parallel to `vectors`; records this slot's extent @@ -93,7 +93,7 @@ the wrapper points to a different array's memory via `setfield!(:ref)`. @inline function _claim_slot!(tp::TypedPool{T}) where {T} tp.n_active += 1 idx = tp.n_active - AUTO_MANAGE && (tp._ac_peak_n_active = max(tp._ac_peak_n_active, idx)) # working-set peak (DCE'd off) + AUTO_MANAGE && (tp._am_peak_n_active = max(tp._am_peak_n_active, idx)) # working-set peak (DCE'd off) if idx > length(tp.vectors) push!(tp.vectors, Vector{T}(undef, 0)) push!(tp.slot_extents, 0) # placeholder slot: backing points elsewhere (reshape!) diff --git a/src/auto_manage.jl b/src/auto_manage.jl index bdbc90dd..325fc4ba 100644 --- a/src/auto_manage.jl +++ b/src/auto_manage.jl @@ -120,18 +120,18 @@ end # `compact!` takes the same factor/shrink_to/min_bytes/active kwargs for every # `AbstractArrayPool`, so one implementation drives CPU and GPU pools alike. # Auto-trim one pool: drop each type's cold slot tail down to its recent working-set peak -# (`_ac_peak_n_active`), then reset that peak for the next observation period. Generic over +# (`_am_peak_n_active`), then reset that peak for the next observation period. Generic over # `foreach_fixed_slot` + `others`, so it serves every backend once its typed pools carry the # field. Drops references only (no buffer swap), so `RUNTIME_CHECK` poison on a dropped slot # survives for any escaped view. function _auto_trim!(pool::AbstractArrayPool) foreach_fixed_slot(pool) do tp - _trim_to!(tp, tp._ac_peak_n_active) - tp._ac_peak_n_active = 0 + _trim_to!(tp, tp._am_peak_n_active) + tp._am_peak_n_active = 0 end for tp in values(pool.others) - _trim_to!(tp, tp._ac_peak_n_active) - tp._ac_peak_n_active = 0 + _trim_to!(tp, tp._am_peak_n_active) + tp._am_peak_n_active = 0 end return nothing end diff --git a/src/bitarray.jl b/src/bitarray.jl index 08f18e5f..7c6a1739 100644 --- a/src/bitarray.jl +++ b/src/bitarray.jl @@ -53,7 +53,7 @@ function get_bitarray!(tp::BitTypedPool, dims::NTuple{N, Int}) where {N} total_len = safe_prod(dims) tp.n_active += 1 idx = tp.n_active - AUTO_MANAGE && (tp._ac_peak_n_active = max(tp._ac_peak_n_active, idx)) # working-set peak (DCE'd off) + AUTO_MANAGE && (tp._am_peak_n_active = max(tp._am_peak_n_active, idx)) # working-set peak (DCE'd off) # 1. Pool expansion needed (new slot) if idx > length(tp.vectors) diff --git a/src/types.jl b/src/types.jl index 9bfe4c0f..9f159aaa 100644 --- a/src/types.jl +++ b/src/types.jl @@ -130,7 +130,7 @@ mutable struct TypedPool{T} <: AbstractTypedPool{T, Vector{T}} # Peak `n_active` reached since the last auto-trim — the recent working-set width. # Written on the hot path (`_claim_slot!`, one `max`, gated by AUTO_MANAGE → DCE'd off); # auto-trim reads it as the slot count to keep, then resets it to 0. Owner-only (non-atomic). - _ac_peak_n_active::Int + _am_peak_n_active::Int end TypedPool{T}() where {T} = TypedPool{T}( @@ -144,7 +144,7 @@ TypedPool{T}() where {T} = TypedPool{T}( 0, # n_active [0], # _checkpoint_n_active: sentinel (n_active=0 at depth=0) [0], # _checkpoint_depths: sentinel (depth=0 = no checkpoint) - 0, # _ac_peak_n_active: no usage observed yet + 0, # _am_peak_n_active: no usage observed yet ) # ============================================================================== @@ -250,7 +250,7 @@ mutable struct BitTypedPool <: AbstractTypedPool{Bool, BitVector} _checkpoint_depths::Vector{Int} # --- Auto-trim telemetry (parallel to TypedPool; see its docstring) --- - _ac_peak_n_active::Int + _am_peak_n_active::Int end BitTypedPool() = BitTypedPool( @@ -262,7 +262,7 @@ BitTypedPool() = BitTypedPool( 0, # n_active [0], # _checkpoint_n_active: sentinel [0], # _checkpoint_depths: sentinel - 0, # _ac_peak_n_active + 0, # _am_peak_n_active ) # ============================================================================== diff --git a/test/cuda/test_auto_integration.jl b/test/cuda/test_auto_integration.jl index 44447cad..948085ab 100644 --- a/test/cuda/test_auto_integration.jl +++ b/test/cuda/test_auto_integration.jl @@ -75,7 +75,7 @@ AAP.disable_auto_manage!() @test test == ref # ★ SAFETY: identical values, no corruption # Deterministic reclamation + self-heal after the run. - pool.float32._ac_peak_n_active = 0 + pool.float32._am_peak_n_active = 0 @atomic pool._trim_requested = true @atomic pool._compact_requested = true @with_pool :cuda p begin diff --git a/test/cuda/test_auto_trim.jl b/test/cuda/test_auto_trim.jl index 6fed3a75..0ef941ee 100644 --- a/test/cuda/test_auto_trim.jl +++ b/test/cuda/test_auto_trim.jl @@ -4,7 +4,7 @@ # second action of the unified auto_manage engine. Design: docs/plans/DESIGN_auto_trim.md # # A `CuTypedPool` records the peak `n_active` reached since the last auto-trim in -# `_ac_peak_n_active` (one hot-path `max` in `_cuda_claim_slot!`, gated by AUTO_MANAGE). +# `_am_peak_n_active` (one hot-path `max` in `_cuda_claim_slot!`, gated by AUTO_MANAGE). # Every `trim_interval`, the base-module Timer sets the pool's `@atomic _trim_requested`; # the owner services it at a `@with_pool :cuda` entry — trimming each type's slot tail # down to that recent peak (a type unused for the period trims to 0), then resetting it. @@ -26,24 +26,24 @@ AAP.disable_auto_manage!() # stop the __init__-started timer for deterministic _registry_len() = lock(() -> length(AAP._AUTO_MANAGE_REGISTRY), AAP._AUTO_MANAGE_LOCK) # ── Data layer ─────────────────────────────────────────────────────────── - @testset "CuTypedPool._ac_peak_n_active (default 0) + pool._trim_requested (default false)" begin + @testset "CuTypedPool._am_peak_n_active (default 0) + pool._trim_requested (default false)" begin pool = CuAdaptiveArrayPool{0}() - @test pool.float32._ac_peak_n_active == 0 + @test pool.float32._am_peak_n_active == 0 @test (@atomic pool._trim_requested) == false @atomic pool._trim_requested = true @test (@atomic pool._trim_requested) == true end - @testset "_cuda_claim_slot! records the peak n_active in _ac_peak_n_active" begin + @testset "_cuda_claim_slot! records the peak n_active in _am_peak_n_active" begin pool = CuAdaptiveArrayPool{0}() checkpoint!(pool) acquire!(pool, Float32, 4); acquire!(pool, Float32, 4); acquire!(pool, Float32, 4) # n_active → 3 rewind!(pool) - @test pool.float32._ac_peak_n_active == 3 # peak captured (not reset by rewind) + @test pool.float32._am_peak_n_active == 3 # peak captured (not reset by rewind) # a narrower second period after a manual reset stays at the new peak - pool.float32._ac_peak_n_active = 0 + pool.float32._am_peak_n_active = 0 checkpoint!(pool); acquire!(pool, Float32, 4); rewind!(pool) - @test pool.float32._ac_peak_n_active == 1 + @test pool.float32._am_peak_n_active == 1 end # ── _trim_to! primitive (generic, dispatches on CuTypedPool) ────────────── @@ -71,23 +71,23 @@ AAP.disable_auto_manage!() # stop the __init__-started timer for deterministic checkpoint!(pool) # wide: 3 concurrent acquire!(pool, Float32, 100); acquire!(pool, Float32, 100); acquire!(pool, Float32, 100) rewind!(pool) - pool.float32._ac_peak_n_active = 0 # start a fresh observation period + pool.float32._am_peak_n_active = 0 # start a fresh observation period checkpoint!(pool); acquire!(pool, Float32, 100); rewind!(pool) # narrowed: peak = 1 - @test pool.float32._ac_peak_n_active == 1 + @test pool.float32._am_peak_n_active == 1 @test length(pool.float32.vectors) == 3 # tail still retained @atomic pool._trim_requested = true AAP._run_auto_manage!(pool) @test length(pool.float32.vectors) == 1 # trimmed to the recent peak - @test pool.float32._ac_peak_n_active == 0 # reset for the next period + @test pool.float32._am_peak_n_active == 0 # reset for the next period @test (@atomic pool._trim_requested) == false # flag consumed end @testset "a type unused for the period trims to 0" begin pool = CuAdaptiveArrayPool{0}() checkpoint!(pool); acquire!(pool, Int32, 1000); acquire!(pool, Int32, 1000); rewind!(pool) - pool.int32._ac_peak_n_active = 0 # new period, then NO Int32 use + pool.int32._am_peak_n_active = 0 # new period, then NO Int32 use @test length(pool.int32.vectors) == 2 @atomic pool._trim_requested = true AAP._run_auto_manage!(pool) @@ -97,7 +97,7 @@ AAP.disable_auto_manage!() # stop the __init__-started timer for deterministic @testset "no-op when _trim_requested is clear" begin pool = CuAdaptiveArrayPool{0}() checkpoint!(pool); acquire!(pool, Float32, 100); acquire!(pool, Float32, 100); rewind!(pool) - pool.float32._ac_peak_n_active = 0 + pool.float32._am_peak_n_active = 0 AAP._run_auto_manage!(pool) # no flag set @test length(pool.float32.vectors) == 2 # untouched end @@ -111,7 +111,7 @@ AAP.disable_auto_manage!() # stop the __init__-started timer for deterministic @testset "peak tracking is live through real acquire! (functional guard)" begin pool = CuAdaptiveArrayPool{0}() checkpoint!(pool); acquire!(pool, Float32, 8); acquire!(pool, Float32, 8); rewind!(pool) - @test pool.float32._ac_peak_n_active >= 2 # the max is tracking the peak + @test pool.float32._am_peak_n_active >= 2 # the max is tracking the peak end # ── Safety (S=1): auto-trim drops the slot ref WITHOUT a buffer swap ────── @@ -126,7 +126,7 @@ AAP.disable_auto_manage!() # stop the __init__-started timer for deterministic @test length(tp.vectors) == 1 v = tp.vectors[1] # the backing an escaped view would share data_before = getfield(v, :data) # its DataRef (compact! would swap this) - pool.float32._ac_peak_n_active = 0 # unused this period → trim to 0 + pool.float32._am_peak_n_active = 0 # unused this period → trim to 0 @atomic pool._trim_requested = true AAP._run_auto_manage!(pool) @test length(tp.vectors) == 0 # slot reference dropped diff --git a/test/metal/test_auto_integration.jl b/test/metal/test_auto_integration.jl index 02e714fe..4f5638f7 100644 --- a/test/metal/test_auto_integration.jl +++ b/test/metal/test_auto_integration.jl @@ -76,7 +76,7 @@ AAP.disable_auto_manage!() @test test == ref # ★ SAFETY: identical values, no corruption # Deterministic reclamation + self-heal after the run. - pool.float32._ac_peak_n_active = 0 + pool.float32._am_peak_n_active = 0 @atomic pool._trim_requested = true @atomic pool._compact_requested = true @with_pool :metal p begin diff --git a/test/metal/test_auto_trim.jl b/test/metal/test_auto_trim.jl index 425cfc2e..313c36b5 100644 --- a/test/metal/test_auto_trim.jl +++ b/test/metal/test_auto_trim.jl @@ -4,7 +4,7 @@ # action of the unified auto_manage engine. Design: docs/plans/DESIGN_auto_trim.md # # A `MetalTypedPool` records the peak `n_active` reached since the last auto-trim in -# `_ac_peak_n_active` (one hot-path `max` in `_metal_claim_slot!`, gated by AUTO_MANAGE). +# `_am_peak_n_active` (one hot-path `max` in `_metal_claim_slot!`, gated by AUTO_MANAGE). # Every `trim_interval`, the base-module Timer sets the pool's `@atomic _trim_requested`; # the owner services it at a `@with_pool :metal` entry — trimming each type's slot tail # down to that recent peak (a type unused for the period trims to 0), then resetting it. @@ -26,24 +26,24 @@ AAP.disable_auto_manage!() # stop the __init__-started timer for deterministic _registry_len() = lock(() -> length(AAP._AUTO_MANAGE_REGISTRY), AAP._AUTO_MANAGE_LOCK) # ── Data layer ─────────────────────────────────────────────────────────── - @testset "MetalTypedPool._ac_peak_n_active (default 0) + pool._trim_requested (default false)" begin + @testset "MetalTypedPool._am_peak_n_active (default 0) + pool._trim_requested (default false)" begin pool = MetalAdaptiveArrayPool{0, METAL_STORAGE}() - @test pool.float32._ac_peak_n_active == 0 + @test pool.float32._am_peak_n_active == 0 @test (@atomic pool._trim_requested) == false @atomic pool._trim_requested = true @test (@atomic pool._trim_requested) == true end - @testset "_metal_claim_slot! records the peak n_active in _ac_peak_n_active" begin + @testset "_metal_claim_slot! records the peak n_active in _am_peak_n_active" begin pool = MetalAdaptiveArrayPool{0, METAL_STORAGE}() checkpoint!(pool) acquire!(pool, Float32, 4); acquire!(pool, Float32, 4); acquire!(pool, Float32, 4) # n_active → 3 rewind!(pool) - @test pool.float32._ac_peak_n_active == 3 # peak captured (not reset by rewind) + @test pool.float32._am_peak_n_active == 3 # peak captured (not reset by rewind) # a narrower second period after a manual reset stays at the new peak - pool.float32._ac_peak_n_active = 0 + pool.float32._am_peak_n_active = 0 checkpoint!(pool); acquire!(pool, Float32, 4); rewind!(pool) - @test pool.float32._ac_peak_n_active == 1 + @test pool.float32._am_peak_n_active == 1 end # ── _trim_to! primitive (generic, dispatches on MetalTypedPool) ─────────── @@ -71,23 +71,23 @@ AAP.disable_auto_manage!() # stop the __init__-started timer for deterministic checkpoint!(pool) # wide: 3 concurrent acquire!(pool, Float32, 100); acquire!(pool, Float32, 100); acquire!(pool, Float32, 100) rewind!(pool) - pool.float32._ac_peak_n_active = 0 # start a fresh observation period + pool.float32._am_peak_n_active = 0 # start a fresh observation period checkpoint!(pool); acquire!(pool, Float32, 100); rewind!(pool) # narrowed: peak = 1 - @test pool.float32._ac_peak_n_active == 1 + @test pool.float32._am_peak_n_active == 1 @test length(pool.float32.vectors) == 3 # tail still retained @atomic pool._trim_requested = true AAP._run_auto_manage!(pool) @test length(pool.float32.vectors) == 1 # trimmed to the recent peak - @test pool.float32._ac_peak_n_active == 0 # reset for the next period + @test pool.float32._am_peak_n_active == 0 # reset for the next period @test (@atomic pool._trim_requested) == false # flag consumed end @testset "a type unused for the period trims to 0" begin pool = MetalAdaptiveArrayPool{0, METAL_STORAGE}() checkpoint!(pool); acquire!(pool, Int32, 1000); acquire!(pool, Int32, 1000); rewind!(pool) - pool.int32._ac_peak_n_active = 0 # new period, then NO Int32 use + pool.int32._am_peak_n_active = 0 # new period, then NO Int32 use @test length(pool.int32.vectors) == 2 @atomic pool._trim_requested = true AAP._run_auto_manage!(pool) @@ -97,7 +97,7 @@ AAP.disable_auto_manage!() # stop the __init__-started timer for deterministic @testset "no-op when _trim_requested is clear" begin pool = MetalAdaptiveArrayPool{0, METAL_STORAGE}() checkpoint!(pool); acquire!(pool, Float32, 100); acquire!(pool, Float32, 100); rewind!(pool) - pool.float32._ac_peak_n_active = 0 + pool.float32._am_peak_n_active = 0 AAP._run_auto_manage!(pool) # no flag set @test length(pool.float32.vectors) == 2 # untouched end @@ -111,7 +111,7 @@ AAP.disable_auto_manage!() # stop the __init__-started timer for deterministic @testset "peak tracking is live through real acquire! (functional guard)" begin pool = MetalAdaptiveArrayPool{0, METAL_STORAGE}() checkpoint!(pool); acquire!(pool, Float32, 8); acquire!(pool, Float32, 8); rewind!(pool) - @test pool.float32._ac_peak_n_active >= 2 # the max is tracking the peak + @test pool.float32._am_peak_n_active >= 2 # the max is tracking the peak end # ── Safety (R=1): auto-trim drops the slot ref WITHOUT a buffer swap ────── @@ -126,7 +126,7 @@ AAP.disable_auto_manage!() # stop the __init__-started timer for deterministic @test length(tp.vectors) == 1 v = tp.vectors[1] # the backing an escaped view would share data_before = getfield(v, :data) # its DataRef (compact! would swap this) - pool.float32._ac_peak_n_active = 0 # unused this period → trim to 0 + pool.float32._am_peak_n_active = 0 # unused this period → trim to 0 @atomic pool._trim_requested = true AAP._run_auto_manage!(pool) @test length(tp.vectors) == 0 # slot reference dropped diff --git a/test/test_auto_integration.jl b/test/test_auto_integration.jl index b0a9603e..ac964829 100644 --- a/test/test_auto_integration.jl +++ b/test/test_auto_integration.jl @@ -93,7 +93,7 @@ AAP.disable_auto_manage!() # Non-vacuity: the reclamation path actually shrinks the pool and the next acquire # self-heals correctly. Reset the recent peak so the next auto-trim drops the whole # tail deterministically, then service one flagged scope entry. - pool.float64._ac_peak_n_active = 0 + pool.float64._am_peak_n_active = 0 @atomic pool._trim_requested = true @atomic pool._compact_requested = true @with_pool p begin diff --git a/test/test_auto_trim.jl b/test/test_auto_trim.jl index 4242d4a7..96451439 100644 --- a/test/test_auto_trim.jl +++ b/test/test_auto_trim.jl @@ -8,7 +8,7 @@ import AdaptiveArrayPools as AAP # unified auto_manage engine). Design: docs/plans/DESIGN_auto_trim.md # # A `TypedPool` records the peak `n_active` reached since the last auto-trim in -# `_ac_peak_n_active` (one hot-path `max` in `_claim_slot!`, gated by AUTO_MANAGE). +# `_am_peak_n_active` (one hot-path `max` in `_claim_slot!`, gated by AUTO_MANAGE). # Every `trim_interval`, the Timer sets the pool's `@atomic _trim_requested`; the # owner services it at a `@with_pool` entry — trimming each type's slot tail down # to that recent peak (a type unused for the period trims to 0), then resetting it. @@ -23,24 +23,24 @@ AAP.disable_auto_manage!() _clear_registry!() = lock(() -> empty!(AAP._AUTO_MANAGE_REGISTRY), AAP._AUTO_MANAGE_LOCK) # ── Data layer ─────────────────────────────────────────────────────────── - @testset "TypedPool._ac_peak_n_active (default 0) + pool._trim_requested (default false)" begin + @testset "TypedPool._am_peak_n_active (default 0) + pool._trim_requested (default false)" begin pool = AdaptiveArrayPool{0}() - @test pool.float64._ac_peak_n_active == 0 + @test pool.float64._am_peak_n_active == 0 @test (@atomic pool._trim_requested) == false @atomic pool._trim_requested = true @test (@atomic pool._trim_requested) == true end - @testset "_claim_slot! records the peak n_active in _ac_peak_n_active" begin + @testset "_claim_slot! records the peak n_active in _am_peak_n_active" begin pool = AdaptiveArrayPool{0}() checkpoint!(pool) acquire!(pool, Float64, 4); acquire!(pool, Float64, 4); acquire!(pool, Float64, 4) # n_active → 3 rewind!(pool) - @test pool.float64._ac_peak_n_active == 3 # peak captured (not reset by rewind) + @test pool.float64._am_peak_n_active == 3 # peak captured (not reset by rewind) # a narrower second period after a manual reset stays at the new peak - pool.float64._ac_peak_n_active = 0 + pool.float64._am_peak_n_active = 0 checkpoint!(pool); acquire!(pool, Float64, 4); rewind!(pool) - @test pool.float64._ac_peak_n_active == 1 + @test pool.float64._am_peak_n_active == 1 end # ── _trim_to! primitive ────────────────────────────────────────────────── @@ -68,23 +68,23 @@ AAP.disable_auto_manage!() checkpoint!(pool) # wide: 3 concurrent acquire!(pool, Float64, 100); acquire!(pool, Float64, 100); acquire!(pool, Float64, 100) rewind!(pool) - pool.float64._ac_peak_n_active = 0 # start a fresh observation period + pool.float64._am_peak_n_active = 0 # start a fresh observation period checkpoint!(pool); acquire!(pool, Float64, 100); rewind!(pool) # narrowed: peak = 1 - @test pool.float64._ac_peak_n_active == 1 + @test pool.float64._am_peak_n_active == 1 @test length(pool.float64.vectors) == 3 # tail still retained @atomic pool._trim_requested = true AAP._run_auto_manage!(pool) @test length(pool.float64.vectors) == 1 # trimmed to the recent peak - @test pool.float64._ac_peak_n_active == 0 # reset for the next period + @test pool.float64._am_peak_n_active == 0 # reset for the next period @test (@atomic pool._trim_requested) == false # flag consumed end @testset "a type unused for the period trims to 0" begin pool = AdaptiveArrayPool{0}() checkpoint!(pool); acquire!(pool, Int64, 1000); acquire!(pool, Int64, 1000); rewind!(pool) - pool.int64._ac_peak_n_active = 0 # new period, then NO Int64 use + pool.int64._am_peak_n_active = 0 # new period, then NO Int64 use @test length(pool.int64.vectors) == 2 @atomic pool._trim_requested = true AAP._run_auto_manage!(pool) @@ -94,7 +94,7 @@ AAP.disable_auto_manage!() @testset "no-op when _trim_requested is clear" begin pool = AdaptiveArrayPool{0}() checkpoint!(pool); acquire!(pool, Float64, 100); acquire!(pool, Float64, 100); rewind!(pool) - pool.float64._ac_peak_n_active = 0 + pool.float64._am_peak_n_active = 0 AAP._run_auto_manage!(pool) # no flag set @test length(pool.float64.vectors) == 2 # untouched end @@ -105,7 +105,7 @@ AAP.disable_auto_manage!() g(p) = (checkpoint!(p); acquire!(p, Float64, 8); acquire!(p, Float64, 8); rewind!(p); nothing) g(pool); g(pool) # warmup @test @allocated(g(pool)) == 0 # the max adds no allocation - @test pool.float64._ac_peak_n_active >= 2 # …and it is tracking the peak + @test pool.float64._am_peak_n_active >= 2 # …and it is tracking the peak end @testset "RUNTIME_CHECK=1: auto-trim drops the slot ref, preserving poison (no buffer swap)" begin @@ -115,7 +115,7 @@ AAP.disable_auto_manage!() @test length(tp.vectors) == 1 backing = tp.vectors[1] # the poisoned backing an escaped view would hold mem_before = getfield(backing, :ref).mem - pool.float64._ac_peak_n_active = 0 # unused this period → trim to 0 + pool.float64._am_peak_n_active = 0 # unused this period → trim to 0 @atomic pool._trim_requested = true AAP._run_auto_manage!(pool) @test length(tp.vectors) == 0 # slot reference dropped From b9dc0afabc11a4c13cf85c9852dea8aed245853f Mon Sep 17 00:00:00 2001 From: Min-Gu Yoo Date: Tue, 30 Jun 2026 14:43:19 -0700 Subject: [PATCH 08/15] fix(compact): active compact! no longer strands reshape! aliases reshape!(pool, A, dims) with ndims(A) != length(dims) stores its reshaped wrapper in a SEPARATE placeholder slot while aliasing A's backing memory. _compact_slot! re-pointed only the wrappers cached AT the compacted slot index, so the cross-slot reshape alias was left on the freed backing after an active=true compact! -- A followed the new memory while the reshape view silently diverged (reproduced with default compact! settings on a 1.6 MB slot, not just contrived parameters). Fix (all three backends): capture the slot's backing identity BEFORE the swap and re-point EVERY cached wrapper that shares it, across all slots -- not just wrappers[slot]: - CPU: match by Memory identity (getfield(w,:ref).mem === old_mem), setfield!(:ref). - Metal/CUDA: match by DataRef refcount identity (getfield(w,:data).rc === old_rc) and migrate via the refcount-balanced _update_*_wrapper_data! helper -- the same .data.rc identity reshape! itself uses to decide buffer sharing. The scan runs in the cold compaction path (which already allocates a backing + copyto!s), so the extra work is negligible; every pool wrapper points at offset 0 of its backing, so Memory/DataRef identity is a precise, offset-safe alias key. TDD: RED test pinned per backend (test/{,metal/,cuda/}test_compact.jl) -- write through one view, read the other, plus a shared-backing assertion -- failed before, passes after. CPU full suite + Metal full suite green; CUDA precompiled + parse-checked (device suite remote). --- ext/AdaptiveArrayPoolsCUDAExt/state.jl | 14 +++++++++---- ext/AdaptiveArrayPoolsMetalExt/state.jl | 14 +++++++++---- src/state.jl | 18 +++++++++++++---- test/cuda/test_compact.jl | 25 +++++++++++++++++++++++ test/metal/test_compact.jl | 25 +++++++++++++++++++++++ test/test_compact.jl | 27 +++++++++++++++++++++++++ 6 files changed, 111 insertions(+), 12 deletions(-) diff --git a/ext/AdaptiveArrayPoolsCUDAExt/state.jl b/ext/AdaptiveArrayPoolsCUDAExt/state.jl index 7ff5714f..b465f8db 100644 --- a/ext/AdaptiveArrayPoolsCUDAExt/state.jl +++ b/ext/AdaptiveArrayPoolsCUDAExt/state.jl @@ -473,16 +473,22 @@ end # makes inactive compaction allocate-only (no device copy / kernel launch). function AdaptiveArrayPools._compact_slot!(tp::CuTypedPool{T}, slot::Int, target::Int, used::Int, copy_live::Bool) where {T} v = @inbounds tp.vectors[slot] + old_rc = getfield(getfield(v, :data), :rc) # OLD device-buffer identity, captured BEFORE the swap nv = CuVector{T}(undef, target) copy_live && copyto!(nv, 1, v, 1, used) _update_cuda_wrapper_data!(v, nv) # backing takes nv's buffer (refcounted) setfield!(v, :dims, (target,)) + # Re-point EVERY cached wrapper sharing this slot's OLD device buffer — not only the wrappers + # cached AT `slot`. A `reshape!` alias lives in a SEPARATE placeholder slot while sharing this + # buffer's DataRef; the per-slot scan missed it, stranding it on the freed buffer after an + # `active=true` compact!. Match by refcount identity (the same `.data.rc` test `reshape!` uses) + # and migrate each alias onto the new buffer via the refcount-balanced helper (dims kept). for wrappers in tp.arr_wrappers wrappers === nothing && continue - slot <= length(wrappers) || continue - w = @inbounds wrappers[slot] - w === nothing && continue - _update_cuda_wrapper_data!(w, v) # held wrapper re-points to new buffer (dims kept) + for w in wrappers + w === nothing && continue + getfield(getfield(w, :data), :rc) === old_rc && _update_cuda_wrapper_data!(w, v) + end end unsafe_free!(getfield(nv, :data)) # drop the temporary's own reference return nothing diff --git a/ext/AdaptiveArrayPoolsMetalExt/state.jl b/ext/AdaptiveArrayPoolsMetalExt/state.jl index add26a27..dd243f75 100644 --- a/ext/AdaptiveArrayPoolsMetalExt/state.jl +++ b/ext/AdaptiveArrayPoolsMetalExt/state.jl @@ -446,16 +446,22 @@ end # makes inactive compaction allocate-only (no device copy / kernel launch). function AdaptiveArrayPools._compact_slot!(tp::MetalTypedPool{T, S}, slot::Int, target::Int, used::Int, copy_live::Bool) where {T, S} v = @inbounds tp.vectors[slot] + old_rc = getfield(getfield(v, :data), :rc) # OLD device-buffer identity, captured BEFORE the swap nv = MtlArray{T, 1, S}(undef, target) copy_live && copyto!(nv, 1, v, 1, used) _update_metal_wrapper_data!(v, nv) # backing takes nv's buffer (refcounted) setfield!(v, :dims, (target,)) + # Re-point EVERY cached wrapper sharing this slot's OLD device buffer — not only the wrappers + # cached AT `slot`. A `reshape!` alias lives in a SEPARATE placeholder slot while sharing this + # buffer's DataRef; the per-slot scan missed it, stranding it on the freed buffer after an + # `active=true` compact!. Match by refcount identity (the same `.data.rc` test `reshape!` uses) + # and migrate each alias onto the new buffer via the refcount-balanced helper (dims kept). for wrappers in tp.arr_wrappers wrappers === nothing && continue - slot <= length(wrappers) || continue - w = @inbounds wrappers[slot] - w === nothing && continue - _update_metal_wrapper_data!(w, v) # held wrapper re-points to new buffer (dims kept) + for w in wrappers + w === nothing && continue + getfield(getfield(w, :data), :rc) === old_rc && _update_metal_wrapper_data!(w, v) + end end unsafe_free!(getfield(nv, :data)) # drop the temporary's own reference return nothing diff --git a/src/state.jl b/src/state.jl index 40ea58a6..5ddf3866 100644 --- a/src/state.jl +++ b/src/state.jl @@ -902,16 +902,26 @@ end # Julia's `Memory` cannot be shrunk in place (any real reclaim must allocate anew). function _compact_slot!(tp::TypedPool{T}, slot::Int, target::Int, used::Int, copy_live::Bool) where {T} v = @inbounds tp.vectors[slot] + old_mem = getfield(getfield(v, :ref), :mem) # the backing Memory, captured BEFORE the swap nv = Vector{T}(undef, target) copy_live && copyto!(nv, 1, v, 1, used) setfield!(v, :size, (target,)) setfield!(v, :ref, getfield(nv, :ref)) + new_ref = getfield(v, :ref) + # Re-point EVERY cached wrapper that aliased this slot's old backing memory — not only the + # wrappers cached AT `slot`. `reshape!(pool, a, dims)` (M≠N) stores its reshaped wrapper in a + # SEPARATE placeholder slot while pointing it at `a`'s (this slot's) memory; a per-slot scan + # missed that alias, so an `active=true` compact! stranded it on the freed backing — `a` + # followed the new memory while the reshape view silently diverged. Matching by `Memory` + # identity catches both: every pool wrapper points at offset 0 of its backing, so any wrapper + # whose `:ref.mem` is the swapped-out memory must be re-pointed to the new backing (`:size` + # unchanged; `target ≥ used` keeps every live element). for wrappers in tp.arr_wrappers wrappers === nothing && continue - slot <= length(wrappers) || continue - w = @inbounds wrappers[slot] - w === nothing && continue - setfield!(w, :ref, getfield(v, :ref)) # :size unchanged + for w in wrappers + w === nothing && continue + getfield(getfield(w, :ref), :mem) === old_mem && setfield!(w, :ref, new_ref) + end end return nothing end diff --git a/test/cuda/test_compact.jl b/test/cuda/test_compact.jl index d818b7e2..96d6500b 100644 --- a/test/cuda/test_compact.jl +++ b/test/cuda/test_compact.jl @@ -53,6 +53,31 @@ rewind!(pool) end + @testset "active=true preserves a live reshape! alias (cross-slot, different dim)" begin + # `reshape!(pool, a, dims)` with M≠N stores the reshaped CuArray wrapper in a SEPARATE + # placeholder slot while SHARING `a`'s device buffer (same DataRef). An active compact! + # that shrinks `a`'s slot must re-point that cross-slot alias too — else `a` follows the + # new buffer while the reshape view is stranded on the freed buffer. Regression for the + # per-slot-only wrapper re-sync in the CUDA `_compact_slot!`. + pool = CuAdaptiveArrayPool{0}() + checkpoint!(pool); big = acquire!(pool, Float32, 200_000); rewind!(pool) # slot 1 high-water + checkpoint!(pool) + a = acquire!(pool, Float32, 100); fill!(a, 1.0f0) # reuse slot 1: bloated (used=100) + b = reshape!(pool, a, 10, 10) # 2-D alias of `a` (placeholder slot 2) + tp = pool.float32 + @test getfield(a, :data).rc === getfield(b, :data).rc # one shared device buffer before compact + @test _ccap(tp.vectors[1]) >= 200_000 + + s = compact!(pool; active = true, min_bytes = 0) # shrink slot 1's device buffer in place + @test s.slots_compacted == 1 + + # The alias must SURVIVE: still one buffer, and a write through `a` is visible in `b`. + @test getfield(a, :data).rc === getfield(b, :data).rc # ← reshape view follows the new buffer + fill!(a, 42.0f0) + @test all(==(42.0f0), Array(b)) # data flows across the alias post-compact + rewind!(pool) + end + @testset "no-op + force_gc + type stability + DisabledPool" begin pool = CuAdaptiveArrayPool{0}() @test compact!(pool) == ZERO diff --git a/test/metal/test_compact.jl b/test/metal/test_compact.jl index 8f925814..942cdac7 100644 --- a/test/metal/test_compact.jl +++ b/test/metal/test_compact.jl @@ -56,6 +56,31 @@ rewind!(pool) end + @testset "active=true preserves a live reshape! alias (cross-slot, different dim)" begin + # `reshape!(pool, a, dims)` with M≠N stores the reshaped MtlArray wrapper in a SEPARATE + # placeholder slot while SHARING `a`'s device buffer (same DataRef). An active compact! + # that shrinks `a`'s slot must re-point that cross-slot alias too — else `a` follows the + # new buffer while the reshape view is stranded on the freed buffer. Regression for the + # per-slot-only wrapper re-sync in the Metal `_compact_slot!`. + pool = MetalAdaptiveArrayPool{0, METAL_STORAGE}() + checkpoint!(pool); big = acquire!(pool, Float32, 200_000); rewind!(pool) # slot 1 high-water + checkpoint!(pool) + a = acquire!(pool, Float32, 100); fill!(a, 1.0f0) # reuse slot 1: bloated (used=100) + b = reshape!(pool, a, 10, 10) # 2-D alias of `a` (placeholder slot 2) + tp = pool.float32 + @test getfield(a, :data).rc === getfield(b, :data).rc # one shared device buffer before compact + @test _mcap(tp.vectors[1]) >= 200_000 + + s = compact!(pool; active = true, min_bytes = 0) # shrink slot 1's device buffer in place + @test s.slots_compacted == 1 + + # The alias must SURVIVE: still one buffer, and a write through `a` is visible in `b`. + @test getfield(a, :data).rc === getfield(b, :data).rc # ← reshape view follows the new buffer + fill!(a, 42.0f0) + @test all(==(42.0f0), Array(b)) # data flows across the alias post-compact + rewind!(pool) + end + @testset "no-op + force_gc + type stability + DisabledPool" begin pool = MetalAdaptiveArrayPool{0, METAL_STORAGE}() @test compact!(pool) == ZERO diff --git a/test/test_compact.jl b/test/test_compact.jl index 7f99c361..264f0024 100644 --- a/test/test_compact.jl +++ b/test/test_compact.jl @@ -430,6 +430,33 @@ const _ZERO_COMPACT = (; slots_compacted = 0, bytes_reclaimed = 0, gc_triggered rewind!(pool) end + @testset "active=true preserves a live reshape! alias (cross-slot, different dim)" begin + # `reshape!(pool, a, dims)` with M≠N stores the reshaped Array wrapper in a SEPARATE + # placeholder slot while aliasing `a`'s backing memory. An active compact! that shrinks + # `a`'s slot must re-point that cross-slot alias too — else `a` follows the new backing + # while the reshape view is stranded on the freed memory (silent divergence). Regression + # for the per-slot-only wrapper re-sync in `_compact_slot!`. + pool = AdaptiveArrayPool{0}() + checkpoint!(pool); big = acquire!(pool, Float64, 200_000); rewind!(pool) # slot 1 high-water + checkpoint!(pool) + a = acquire!(pool, Float64, 100); a .= 1.0 # reuse slot 1: bloated (used=100) + b = reshape!(pool, a, 10, 10) # 2-D alias of `a` (placeholder slot 2) + tp = pool.float64 + @test a[1] == b[1, 1] == 1.0 # aliased before compact + @test _cap(tp.vectors[1]) >= 200_000 # bloated → will be compacted + + s = compact!(pool; active = true) # shrinks slot 1's backing in place + @test s.slots_compacted == 1 + + # The alias must SURVIVE: writes through either view are visible in the other. + a[1] = 42.0 + @test b[1, 1] == 42.0 # ← reshape view follows the new backing + b[2, 1] = 7.0 # b[2,1] is column-major a[2] + @test a[2] == 7.0 # …and the reverse direction holds + @test pointer(a) == pointer(b) # both wrappers share one backing + rewind!(pool) + end + @testset "extreme kwargs never throw (shrink target clamped to capacity)" begin pool = _inactive_bloated_pool(1_000_000, 100) # shrink_to so large that ceil(Int, shrink_to*used) would overflow Int — From fc738c5ee05ffd007f0bac01dfb703b956166863 Mon Sep 17 00:00:00 2001 From: Min-Gu Yoo Date: Tue, 30 Jun 2026 15:48:40 -0700 Subject: [PATCH 09/15] docs: document auto_manage (Automatic Memory Management page + API/config) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Features page covering the background auto-compact/auto-trim engine — what it reclaims, the @with_pool safepoint model (why it can't change results), default-on, the enable/disable knobs, the zero-alloc trade-off + auto_manage=false escape hatch, and manual compact!/trim!. Add compact! + the auto_manage symbols to the API reference tables and an auto_manage preference section to Configuration. Also fix a pre-existing broken [Safety](safety.md) link in configuration.md (→ ../safety/overview.md). --- docs/make.jl | 1 + docs/src/features/auto-manage.md | 96 ++++++++++++++++++++++++++++++ docs/src/features/configuration.md | 15 ++++- docs/src/reference/api.md | 5 ++ 4 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 docs/src/features/auto-manage.md diff --git a/docs/make.jl b/docs/make.jl index b5bd5d69..5f25be26 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -135,6 +135,7 @@ makedocs( "Bit Arrays" => "features/bit-arrays.md", "CUDA Support" => "features/cuda-support.md", "Metal Support" => "features/metal-support.md", + "Automatic Memory Management" => "features/auto-manage.md", "Configuration" => "features/configuration.md", ], "Reference" => [ diff --git a/docs/src/features/auto-manage.md b/docs/src/features/auto-manage.md new file mode 100644 index 00000000..1c02c2a2 --- /dev/null +++ b/docs/src/features/auto-manage.md @@ -0,0 +1,96 @@ +# Automatic Memory Management + +A pool keeps buffers around so the next `acquire!` is allocation-free — that reuse is the +whole point. But over a long-running program two kinds of slack accumulate: + +- **Over-sized slots** — a slot that once held a large array keeps that large backing buffer + even after it is reused for small arrays (the buffer only ever grows). +- **Unused slots** — a type or concurrency level that was needed at a peak but isn't part of + the recent working set still has its slots retained. + +`auto_manage` reclaims both in the background, GC-style, so you don't have to call +`compact!` / `trim!` by hand. **It is on by default.** + +## What it does + +One background `Timer` drives two actions, on independent cadences: + +| Action | Reclaims | Default cadence | +|-----------------|-----------------------------------------------------|-----------------| +| **auto-compact** | shrinks an over-allocated slot's backing **in place** | every `interval` (30 s) | +| **auto-trim** | drops slots beyond the **recent working-set peak** | every `trim_interval` (120 s) | + +A slot is auto-compacted only when it is genuinely bloated — backing capacity `≥ factor ×` +its last use **and** the reclaim is `≥ min_bytes` (1 MiB) — so a steady-state pool is left +untouched. Auto-trim drops each type's slots down to the largest concurrency seen in the last +period (a type unused for the whole period trims to zero). + +## When (and where) the work happens + +The `Timer` thread only ever sets a flag; it never touches pool data. Your **owner task** +performs the actual reclamation at the next `@with_pool` **entry**, at the top-level +safepoint (`depth == 1`) where nothing is borrowed: + +```julia +@with_pool p begin # ← if a request is pending, it is serviced HERE, before your code + x = acquire!(p, Float64, 1000) + # ... +end +``` + +This means reclamation never runs mid-computation and never on the background thread, so it +**cannot change any result** — it only changes memory layout. An idle task that never enters a +`@with_pool` scope is simply never serviced (and a finished task's pool is freed by the GC). + +!!! note "Targets the task-local pool" + Auto-manage acts on the **task-local pool** (`get_task_local_pool()`) — the one `@with_pool` + uses and the only one auto-registered. A hand-made `AdaptiveArrayPool()` is not auto-managed; + call `compact!` / `trim!` on it directly, or register it explicitly. + +## Controlling it + +```julia +# Re-tune the background timer (any subset of keywords) +enable_auto_manage!(; interval = 30.0, trim_interval = 120.0, + factor = 10, shrink_to = 1.5, min_bytes = 2^20) + +enable_auto_manage!(; trim_interval = Inf) # compact-only: disable auto-trim +disable_auto_manage!() # stop the background timer +auto_manage_enabled() # → Bool +``` + +`disable_auto_manage!()` only stops the timer for the current session; the feature re-arms on +the next Julia start. To turn it off permanently, use the Preference below. + +## Performance & turning it off + +The always-on cost is a single sub-nanosecond atomic flag read per `@with_pool` entry plus one +`Int` comparison per `acquire!` — both dead-code-eliminated when the feature is compiled off. + +The one caveat: a `@with_pool` entry that *services* a pending request allocates the new +right-sized buffer(s), so that particular entry is **not** zero-allocation. For a guaranteed +zero-allocation hot path (e.g. a hard-real-time loop or an allocation benchmark), compile the +feature out via the `auto_manage` preference (see [Configuration](@ref)): + +```toml +# LocalPreferences.toml (restart Julia to take effect) +[AdaptiveArrayPools] +auto_manage = false +``` + +With the preference off, the `@with_pool` hook is eliminated entirely — `enable_auto_manage!` +becomes a no-op-with-warning, and you reclaim memory only when you call `compact!` / `trim!` +yourself. + +## Manual control + +`auto_manage` is just a policy over the manual primitives, which you can always call directly: + +- `compact!(pool)` — shrink over-allocated backing buffers now. +- `trim!(pool)` — drop inactive retained buffers now (see [Essential API](@ref)). + +## Backends & versions + +CPU, CUDA, and Metal, on Julia 1.12+. On Julia ≤ 1.11 the whole feature is a defined, +exported **no-op** (`AUTO_MANAGE == false`, `auto_manage_enabled() == false`), so code using the +API runs unchanged across the full supported Julia range. diff --git a/docs/src/features/configuration.md b/docs/src/features/configuration.md index 2211a352..4c7a54b2 100644 --- a/docs/src/features/configuration.md +++ b/docs/src/features/configuration.md @@ -55,7 +55,7 @@ All pooling code is **completely eliminated at compile time** (zero overhead). ## Compile-time: RUNTIME_CHECK -Enable runtime safety checks to catch pool-escape bugs. See [Safety](safety.md) for full details. +Enable runtime safety checks to catch pool-escape bugs. See [Safety](../safety/overview.md) for full details. ```toml # LocalPreferences.toml @@ -111,6 +111,18 @@ set_cache_ways!(8) **When to increase**: If your CUDA code or Julia ≤1.11 code alternates between more than 4 dimension patterns per `acquire!` call, increase `cache_ways` to avoid cache eviction (~100 bytes header per miss). +## Compile-time: `auto_manage` + +Master switch for the background memory reclamation engine (see [Automatic Memory Management](@ref)). **Default `true`** — a background timer periodically auto-compacts and auto-trims the task-local pool. + +```toml +# LocalPreferences.toml +[AdaptiveArrayPools] +auto_manage = false # compile the feature out; restart Julia to take effect +``` + +Setting it to `false` dead-code-eliminates the `@with_pool` reclamation hook entirely, guaranteeing a zero-allocation hot path; `enable_auto_manage!` then becomes a no-op-with-warning and you reclaim memory only via manual `compact!` / `trim!`. The background timer is also tunable at runtime (no restart) with `enable_auto_manage!` / `disable_auto_manage!`. + ## Summary | Setting | Scope | Restart? | Priority | Affects | @@ -118,4 +130,5 @@ set_cache_ways!(8) | `use_pooling` | Compile-time | Yes | ⭐ Primary | All macros, `acquire!` behavior | | `runtime_check` | Compile-time | Yes | Safety | Poisoning, invalidation, escape detection | | `cache_ways` | Compile-time | Yes | Advanced | `acquire!` N-D caching (≤1.11 / CUDA only) | +| `auto_manage` | Compile-time | Yes | Optional | Background auto-compact/auto-trim (default on) | | `MAYBE_POOLING` | Runtime | No | Optional | `@maybe_with_pool` only | diff --git a/docs/src/reference/api.md b/docs/src/reference/api.md index d9b83c49..fbbd7039 100644 --- a/docs/src/reference/api.md +++ b/docs/src/reference/api.md @@ -25,6 +25,7 @@ | `get_task_local_pool()` | Returns the task-local pool instance. | | `reset!(pool)` | Resets active-slot counters to 0 but **keeps** all buffers for reuse. | | `trim!(pool; force_gc=false)` | Releases **inactive** retained buffers, keeping active arrays. Returns `(; slots_released, wrappers_released, estimated_bytes_released, gc_triggered)`. Works on CPU, CUDA, and Metal pools. Reclaims on Julia 1.12+; defined no-op (zero summary + one-time warning) on older Julia. | +| `compact!(pool; factor=10, shrink_to=1.5, min_bytes=2^20, active=true, force_gc=false)` | Shrinks over-allocated backing buffers **in place** (held arrays follow). Returns `(; slots_compacted, bytes_reclaimed, gc_triggered)`. Usually run automatically — see [Automatic Memory Management](@ref). | | `empty!(pool)` | Clears all internal storage, releasing **all** memory. | ### Convenience Functions @@ -56,6 +57,10 @@ Default element type is `Float64` (CPU) or `Float32` (CUDA). | `MAYBE_POOLING` | Runtime `Ref{Bool}` for `@maybe_with_pool`. (alias: `MAYBE_POOLING_ENABLED`) | | `RUNTIME_CHECK` | Compile-time `Int` constant (0=off, 1=on). Set via `runtime_check` preference. Restart required. | | `set_cache_ways!(n)` | Set N-way cache size (≤1.11 / CUDA only; no effect on Julia 1.12+ CPU). | +| `enable_auto_manage!(; …)` | (Re)start the background auto-compact/auto-trim timer. See [Automatic Memory Management](@ref). | +| `disable_auto_manage!()` | Stop the background timer for this session. | +| `auto_manage_enabled()` | `Bool`: is the background timer running? | +| `AUTO_MANAGE` | Compile-time `Bool` constant. Set via `auto_manage` preference (default `true`); `false` compiles the feature out. | --- From 1b5fd3f56ee614c24079513522a24d07d9ab078e Mon Sep 17 00:00:00 2001 From: Min-Gu Yoo Date: Tue, 30 Jun 2026 15:49:01 -0700 Subject: [PATCH 10/15] fix(auto_manage): address PR #47 review + raise patch coverage to 100% MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes (GitHub Copilot): - Legacy (Julia <1.12) `enable_auto_manage!` shim now accepts (and ignores) the new `trim_interval` keyword, so the public API is call-compatible across all supported Julia (previously a MethodError on old Julia when passing trim_interval). - Refresh the `enable_auto_manage!` docstring: show `trim_interval`, document auto-trim, generalize the "what gets managed" note (compaction + trim), drop the typo. - Fix rename artifacts from the auto_compact->auto_manage token replace: "auto-manageion" -> "auto-management" / "auto-manageed" -> "auto-managed" (15 comments across 10 files), and the misleading "Auto-Compact" file/section headers -> "Auto-Manage". Patch coverage (was 94.07% on src/auto_manage.jl, the only file below 100%): - Cover the `_auto_trim!` `others`-type loop with a non-fixed-slot (UInt8) auto-trim test. - Cover the two `_run_auto_manage!` try/catch+@warn defensive branches by parking a non-pool object in both others collections (values(others) for trim, _others_values for CPU compact) — mirrors the existing throwing-sweep test. src/auto_manage.jl now 100% (90/90). --- .../AdaptiveArrayPoolsCUDAExt.jl | 2 +- ext/AdaptiveArrayPoolsCUDAExt/auto_manage.jl | 2 +- .../task_local_pool.jl | 2 +- ext/AdaptiveArrayPoolsMetalExt/auto_manage.jl | 2 +- .../task_local_pool.jl | 2 +- src/auto_manage.jl | 46 ++++++++++--------- src/legacy/auto_manage.jl | 21 +++++---- src/macros.jl | 2 +- src/task_local_pool.jl | 2 +- test/cuda/test_auto_manage.jl | 2 +- test/metal/test_auto_manage.jl | 2 +- test/runtests.jl | 2 +- test/test_auto_manage.jl | 20 +++++++- test/test_auto_manage_legacy.jl | 6 ++- test/test_auto_trim.jl | 13 ++++++ 15 files changed, 82 insertions(+), 44 deletions(-) diff --git a/ext/AdaptiveArrayPoolsCUDAExt/AdaptiveArrayPoolsCUDAExt.jl b/ext/AdaptiveArrayPoolsCUDAExt/AdaptiveArrayPoolsCUDAExt.jl index 766eb288..f8602e9b 100644 --- a/ext/AdaptiveArrayPoolsCUDAExt/AdaptiveArrayPoolsCUDAExt.jl +++ b/ext/AdaptiveArrayPoolsCUDAExt/AdaptiveArrayPoolsCUDAExt.jl @@ -33,7 +33,7 @@ using CUDA # State management (checkpoint!, rewind!, reset!, empty!) include("state.jl") - # Auto-compact: service the timer-flagged request at `@with_pool :cuda` entry + # Auto-manage: service the timer-flagged request at `@with_pool :cuda` entry include("auto_manage.jl") # Safety: poisoning, escape detection, borrow tracking diff --git a/ext/AdaptiveArrayPoolsCUDAExt/auto_manage.jl b/ext/AdaptiveArrayPoolsCUDAExt/auto_manage.jl index ee664b40..02e9ae5b 100644 --- a/ext/AdaptiveArrayPoolsCUDAExt/auto_manage.jl +++ b/ext/AdaptiveArrayPoolsCUDAExt/auto_manage.jl @@ -1,5 +1,5 @@ # ============================================================================== -# CUDA Auto-Compact: service a flagged CUDA pool at its `@with_pool :cuda` entry. +# CUDA Auto-Manage: service a flagged CUDA pool at its `@with_pool :cuda` entry. # ============================================================================== # # Parity with the CPU `_maybe_auto_manage!(::AdaptiveArrayPool)` in src/auto_manage.jl diff --git a/ext/AdaptiveArrayPoolsCUDAExt/task_local_pool.jl b/ext/AdaptiveArrayPoolsCUDAExt/task_local_pool.jl index 3f6f0cc8..615cb092 100644 --- a/ext/AdaptiveArrayPoolsCUDAExt/task_local_pool.jl +++ b/ext/AdaptiveArrayPoolsCUDAExt/task_local_pool.jl @@ -36,7 +36,7 @@ Uses `Dict{Int, CuAdaptiveArrayPool{RUNTIME_CHECK}}` in task-local storage, keye if pool === nothing pool = CuAdaptiveArrayPool() # Uses RUNTIME_CHECK for initial S pools[dev_id] = pool - # Register for auto-manageion (gated by the compile-time const; mirrors CPU + # Register for auto-management (gated by the compile-time const; mirrors CPU # get_task_local_pool). One entry per (task, device) pool. DCE'd when the # Preference is off, so no registry traffic on the zero-overhead build. AdaptiveArrayPools.AUTO_MANAGE && AdaptiveArrayPools.register_auto_manage!(pool) diff --git a/ext/AdaptiveArrayPoolsMetalExt/auto_manage.jl b/ext/AdaptiveArrayPoolsMetalExt/auto_manage.jl index 0c761fc4..7738df03 100644 --- a/ext/AdaptiveArrayPoolsMetalExt/auto_manage.jl +++ b/ext/AdaptiveArrayPoolsMetalExt/auto_manage.jl @@ -1,5 +1,5 @@ # ============================================================================== -# Metal Auto-Compact: service a flagged Metal pool at its `@with_pool :metal` entry. +# Metal Auto-Manage: service a flagged Metal pool at its `@with_pool :metal` entry. # ============================================================================== # # Parity with the CPU `_maybe_auto_manage!(::AdaptiveArrayPool)` in src/auto_manage.jl. diff --git a/ext/AdaptiveArrayPoolsMetalExt/task_local_pool.jl b/ext/AdaptiveArrayPoolsMetalExt/task_local_pool.jl index 457de3bb..eb0a8d89 100644 --- a/ext/AdaptiveArrayPoolsMetalExt/task_local_pool.jl +++ b/ext/AdaptiveArrayPoolsMetalExt/task_local_pool.jl @@ -37,7 +37,7 @@ Uses `Dict{UInt64, MetalAdaptiveArrayPool{RUNTIME_CHECK, METAL_STORAGE}}` in tas if pool === nothing pool = MetalAdaptiveArrayPool() # Uses RUNTIME_CHECK for initial R pools[dev_key] = pool - # Register for auto-manageion (gated by the compile-time const; mirrors CPU + # Register for auto-management (gated by the compile-time const; mirrors CPU # get_task_local_pool). One entry per (task, device) pool. DCE'd when the # Preference is off, so no registry traffic on the zero-overhead build. AdaptiveArrayPools.AUTO_MANAGE && AdaptiveArrayPools.register_auto_manage!(pool) diff --git a/src/auto_manage.jl b/src/auto_manage.jl index 325fc4ba..8df0c4bd 100644 --- a/src/auto_manage.jl +++ b/src/auto_manage.jl @@ -1,11 +1,12 @@ # ============================================================================== -# Auto-Compact: timer-driven background capacity compaction. +# Auto-Manage: timer-driven background memory management (compaction + trim). # Design: docs/plans/DESIGN_auto_manage.md # -# A single global Timer periodically *requests* compaction by setting each live -# pool's `@atomic _compact_requested` flag (via a WeakRef registry). Each pool's -# owner task *executes* the request — the full `compact!` — at its next `@with_pool` -# ENTRY (the `_current_depth == 1` safepoint), where nothing is borrowed. +# A single global Timer periodically *requests* maintenance by setting each live pool's +# `@atomic _compact_requested` flag every tick (and `@atomic _trim_requested` every +# `trim_interval`) via a WeakRef registry. Each pool's owner task *executes* the request — +# the full `compact!`, plus auto-trim of cold slots — at its next `@with_pool` ENTRY +# (the `_current_depth == 1` safepoint), where nothing is borrowed. # # Tier 1 (compile-time): `AUTO_MANAGE` gates whether the scope-entry flag check is # emitted (macros.jl) and whether pools register (task_local_pool.jl). Default ON @@ -18,7 +19,7 @@ using Preferences: @load_preference """ AUTO_MANAGE::Bool -Compile-time master switch (Tier 1) for auto-manageion. Default `true`: the scope-entry +Compile-time master switch (Tier 1) for auto-management. Default `true`: the scope-entry flag check is emitted (a ~0.6 ns inlined check per `@with_pool` entry), new task-local pools auto-register, and `__init__` auto-starts the background Timer. Set `false` in `LocalPreferences.toml` (then restart) to compile the feature OUT entirely — the hook is @@ -34,7 +35,7 @@ auto_manage = false """ const AUTO_MANAGE = @load_preference("auto_manage", true)::Bool -# Registry of pools eligible for auto-manageion. `WeakRef` so a pool dies with its +# Registry of pools eligible for auto-management. `WeakRef` so a pool dies with its # task; dead refs are swap-removed on each sweep. All access under the lock below. const _AUTO_MANAGE_REGISTRY = WeakRef[] const _AUTO_MANAGE_LOCK = ReentrantLock() @@ -221,12 +222,15 @@ function _stop_auto_manage_timer_unlocked!() end """ - enable_auto_manage!(; interval = 30.0, factor = 10, shrink_to = 1.5, - min_bytes = 2^20, active = true) + enable_auto_manage!(; interval = 30.0, trim_interval = 120.0, factor = 10, + shrink_to = 1.5, min_bytes = 2^20, active = true) Start (or restart) the background auto-manage `Timer` with the given config. Every -`interval` seconds it flags every registered pool; each pool then runs the full -`compact!` at its next `@with_pool` **entry** (the `_current_depth == 1` safepoint). +`interval` seconds it flags every registered pool to **auto-compact** (shrink over-allocated +backing buffers in place); every `trim_interval` seconds it additionally flags an +**auto-trim** (drop slots beyond the recent working-set peak). Each pool services its pending +flags at its next `@with_pool` **entry** (the `_current_depth == 1` safepoint). A non-finite +`trim_interval` (e.g. `Inf`) disables auto-trim, leaving compaction only. Requires the `auto_manage` compile-time Preference (`AUTO_MANAGE`) for the scope-entry hook and pool auto-registration. With the Preference **off**, the `@with_pool` hook is @@ -235,16 +239,16 @@ flags), nothing ever *services* those flags — no automatic compaction happens, registering pools manually does not change that. Set the Preference and restart, or call `compact!` yourself. -!!! note "What gets compacted, when, and the zero-alloc trade-off" - Auto-compaction targets the **task-local pool** (`get_task_local_pool()`) — the pool - `@with_pool` uses and the only one that auto-registers. A hand-made `AdaptiveArrayPool()` - is **not** auto-registered (so not auto-manageed); `register_auto_manage!` it explicitly - to include it in the sweep, or just call `compact!` on it directly. The work - runs at a `@with_pool` **entry** (`_current_depth == 1`), so an idle task with no - `@with_pool` activity won't compact until it next enters a scope. Because a compaction - allocates the new right-sized backing(s), a `@with_pool` that services a pending request - is **not** strictly zero-allocation; set `auto_manage = false` (Preference) for a - guaranteed-zero-alloc hot path. +!!! note "What gets managed, when, and the zero-alloc trade-off" + Auto-management (compaction + trim) targets the **task-local pool** + (`get_task_local_pool()`) — the pool `@with_pool` uses and the only one that + auto-registers. A hand-made `AdaptiveArrayPool()` is **not** auto-registered (so not + auto-managed); `register_auto_manage!` it explicitly to include it in the sweep, or just + call `compact!` / `trim!` on it directly. The work runs at a `@with_pool` **entry** + (`_current_depth == 1`), so an idle task with no `@with_pool` activity won't be serviced + until it next enters a scope. Because a compaction allocates the new right-sized + backing(s), a `@with_pool` that services a pending request is **not** strictly + zero-allocation; set `auto_manage = false` (Preference) for a guaranteed-zero-alloc hot path. See also [`disable_auto_manage!`](@ref), [`auto_manage_enabled`](@ref). """ diff --git a/src/legacy/auto_manage.jl b/src/legacy/auto_manage.jl index 0745d3b6..6aec7e83 100644 --- a/src/legacy/auto_manage.jl +++ b/src/legacy/auto_manage.jl @@ -1,12 +1,12 @@ # ============================================================================== -# Auto-Compact — legacy (Julia < 1.12) no-op shims. +# Auto-Manage — legacy (Julia < 1.12) no-op shims. # # The modern src/auto_manage.jl is NOT included on the legacy path, but the SHARED # macros.jl and task_local_pool.jl reference AUTO_MANAGE / _maybe_auto_manage! / # register_auto_manage!. Define them here as no-ops so the shared code compiles and the # generated @with_pool scope-entry hook DCEs away (AUTO_MANAGE === false). The public # enable/disable/enabled API stays callable across the whole supported Julia range. -# Upgrade to Julia 1.12+ for actual background auto-manageion. +# Upgrade to Julia 1.12+ for actual background auto-management. # ============================================================================== # Compile-time OFF: constant-folds the shared @with_pool scope-entry hook to nothing. @@ -19,18 +19,19 @@ register_auto_manage!(pool) = nothing @inline _maybe_auto_manage!(::Any) = nothing """ - enable_auto_manage!(; interval = 30.0, factor = 10, shrink_to = 1.5, - min_bytes = 2^20, active = true) + enable_auto_manage!(; interval = 30.0, trim_interval = 120.0, factor = 10, + shrink_to = 1.5, min_bytes = 2^20, active = true) -No-op on Julia < 1.12 (the legacy pool architecture has no capacity compaction). Warns -once. Upgrade to Julia 1.12+ for background auto-manageion. +No-op on Julia < 1.12 (the legacy pool architecture has no capacity management). Warns +once. Upgrade to Julia 1.12+ for background auto-management. Accepts (and ignores) the same +keywords as the 1.12+ method, so the public API is call-compatible across all supported Julia. """ function enable_auto_manage!(; - interval::Real = 30.0, factor::Real = 10, shrink_to::Real = 1.5, - min_bytes::Int = 2^20, active::Bool = true, + interval::Real = 30.0, trim_interval::Real = 120.0, factor::Real = 10, + shrink_to::Real = 1.5, min_bytes::Int = 2^20, active::Bool = true, ) @warn "enable_auto_manage! is a no-op on Julia < 1.12 (legacy pool architecture). " * - "Upgrade to Julia 1.12+ for background auto-manageion." maxlog = 1 + "Upgrade to Julia 1.12+ for background auto-management." maxlog = 1 return nothing end @@ -44,6 +45,6 @@ disable_auto_manage!() = nothing """ auto_manage_enabled() -> Bool -Always `false` on Julia < 1.12 (no auto-manageion). See [`enable_auto_manage!`](@ref). +Always `false` on Julia < 1.12 (no auto-management). See [`enable_auto_manage!`](@ref). """ auto_manage_enabled() = false diff --git a/src/macros.jl b/src/macros.jl index 2b4b6cb3..8f331c68 100644 --- a/src/macros.jl +++ b/src/macros.jl @@ -1592,7 +1592,7 @@ const _WARN_LEAKED_SCOPE_REF = GlobalRef(@__MODULE__, :_warn_leaked_scope) const _REWIND_REF = GlobalRef(@__MODULE__, :rewind!) const _LAZY_REWIND_REF = GlobalRef(@__MODULE__, :_lazy_rewind!) -# Auto-compact scope-ENTRY hook, generated before the checkpoint in every `@with_pool`. +# Auto-manage scope-ENTRY hook, generated before the checkpoint in every `@with_pool`. # Gated by the `AUTO_MANAGE` compile-time const (constant-folded → DCE'd to nothing when # off → zero cost). Dispatches through `_maybe_auto_manage!` so non-CPU (GPU) pools safely # no-op. Placed BEFORE the checkpoint, so `_current_depth == 1` ⇒ the OUTERMOST scope is diff --git a/src/task_local_pool.jl b/src/task_local_pool.jl index 61f79c42..5310da5f 100644 --- a/src/task_local_pool.jl +++ b/src/task_local_pool.jl @@ -96,7 +96,7 @@ type-asserts directly to `AdaptiveArrayPool{RUNTIME_CHECK}`. # This branch is rarely taken (only once per Task) pool = AdaptiveArrayPool() task_local_storage(_POOL_KEY, pool) - # Register for background auto-manageion (DCE'd to nothing when AUTO_MANAGE is off). + # Register for background auto-management (DCE'd to nothing when AUTO_MANAGE is off). AUTO_MANAGE && register_auto_manage!(pool) end diff --git a/test/cuda/test_auto_manage.jl b/test/cuda/test_auto_manage.jl index 950868ac..e041bb90 100644 --- a/test/cuda/test_auto_manage.jl +++ b/test/cuda/test_auto_manage.jl @@ -127,7 +127,7 @@ AAP.disable_auto_manage!() # stop the __init__-started timer for deterministic end @test (@atomic pool._compact_requested) == false # hook consumed the request - @test _ccap(pool.float32.vectors[1]) < cap0 # auto-manageed, no manual compact! + @test _ccap(pool.float32.vectors[1]) < cap0 # auto-managed, no manual compact! empty!(pool) _clear_registry!() end diff --git a/test/metal/test_auto_manage.jl b/test/metal/test_auto_manage.jl index bbbc2c56..4a2444be 100644 --- a/test/metal/test_auto_manage.jl +++ b/test/metal/test_auto_manage.jl @@ -127,7 +127,7 @@ AAP.disable_auto_manage!() # stop the __init__-started timer for deterministic end @test (@atomic pool._compact_requested) == false # hook consumed the request - @test _mcap(pool.float32.vectors[1]) < cap0 # auto-manageed, no manual compact! + @test _mcap(pool.float32.vectors[1]) < cap0 # auto-managed, no manual compact! empty!(pool) _clear_registry!() end diff --git a/test/runtests.jl b/test/runtests.jl index c91182ec..49c602af 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -4,7 +4,7 @@ using AdaptiveArrayPools: get_typed_pool! import AdaptiveArrayPools: checkpoint!, rewind! # AUTO_MANAGE defaults on, so __init__ auto-starts the background timer. Stop it so the -# suite is deterministic; testsets that exercise auto-manageion enable it explicitly. +# suite is deterministic; testsets that exercise auto-management enable it explicitly. AdaptiveArrayPools.disable_auto_manage!() # Version-specific helpers (always defined, even for ARGS path) diff --git a/test/test_auto_manage.jl b/test/test_auto_manage.jl index 0564c085..40b61b6d 100644 --- a/test/test_auto_manage.jl +++ b/test/test_auto_manage.jl @@ -162,6 +162,24 @@ AAP.disable_auto_manage!() # stop the __init__-started timer for deterministic _clear_registry!() end + @testset "_run_auto_manage! swallows per-action failures (trim + compact catch)" begin + # The owner-side service must never surface a maintenance failure at the `@with_pool` + # boundary: each action is wrapped in try/catch + @warn. Force both to throw by parking a + # non-pool object in the others collections (mirrors the throwing-sweep test above), with + # both flags set. `_auto_trim!` iterates `values(others)` (backend-generic); CPU `compact!` + # iterates the `_others_values` cache — a real type registers in both, so we mirror that. + pool = AdaptiveArrayPool{0}() + pool.others[String] = "not a typed pool" # _auto_trim! iterates values(others) + push!(pool._others_values, "not a typed pool") # compact! iterates _others_values + @atomic pool._trim_requested = true + @atomic pool._compact_requested = true + with_logger(NullLogger()) do # silence the two EXPECTED @warns + @test (AAP._run_auto_manage!(pool); true) # both catches fire; does NOT rethrow + end + @test (@atomic pool._trim_requested) == false # flags still consumed (reset before work) + @test (@atomic pool._compact_requested) == false + end + # ── Scope-entry hook ───────────────────────────────────────────────────── @testset "_maybe_auto_manage! fires only at _current_depth == 1" begin pool = _inactive_bloated(1_000_000, 100) @@ -243,7 +261,7 @@ AAP.disable_auto_manage!() # stop the __init__-started timer for deterministic end @test (@atomic pool._compact_requested) == false # hook consumed the request - @test _cap(pool.float64.vectors[1]) < cap0 # auto-manageed, no manual compact! + @test _cap(pool.float64.vectors[1]) < cap0 # auto-managed, no manual compact! empty!(pool) _clear_registry!() end diff --git a/test/test_auto_manage_legacy.jl b/test/test_auto_manage_legacy.jl index 1b8e2dfd..8c54c7db 100644 --- a/test/test_auto_manage_legacy.jl +++ b/test/test_auto_manage_legacy.jl @@ -14,7 +14,9 @@ using AdaptiveArrayPools: get_task_local_pool # enable! warns once and stays a no-op; disable!/enabled are safe and idempotent. @test (@test_logs (:warn,) match_mode = :any enable_auto_manage!()) === nothing @test auto_manage_enabled() == false - @test enable_auto_manage!(interval = 5.0, factor = 20, active = false) === nothing + # Accepts the full 1.12+ keyword set (incl. `trim_interval`) so the public API is + # call-compatible across all supported Julia — passing it must not MethodError. + @test enable_auto_manage!(interval = 5.0, trim_interval = 60.0, factor = 20, active = false) === nothing @test disable_auto_manage!() === nothing @test auto_manage_enabled() == false @@ -22,7 +24,7 @@ using AdaptiveArrayPools: get_task_local_pool @test AdaptiveArrayPools.register_auto_manage!(get_task_local_pool()) === nothing @test AdaptiveArrayPools._maybe_auto_manage!(get_task_local_pool()) === nothing - # @with_pool still works on legacy (the hook DCEs to nothing; no auto-manageion). + # @with_pool still works on legacy (the hook DCEs to nothing; no auto-management). r = @with_pool pool begin x = acquire!(pool, Float64, 4) x .= 1.0 diff --git a/test/test_auto_trim.jl b/test/test_auto_trim.jl index 96451439..86952faf 100644 --- a/test/test_auto_trim.jl +++ b/test/test_auto_trim.jl @@ -147,6 +147,19 @@ AAP.disable_auto_manage!() AAP.disable_auto_manage!() _clear_registry!() end + + @testset "auto-trim reclaims a non-fixed-slot (`others`) type" begin + # `_auto_trim!` trims the fixed slots AND every fallback type in `pool.others`. + # UInt8 is not a fixed slot (FIXED_SLOT_FIELDS), so this exercises the `others` loop. + pool = AdaptiveArrayPool{0}() + checkpoint!(pool); acquire!(pool, UInt8, 100); acquire!(pool, UInt8, 100); rewind!(pool) + tp = AAP.get_typed_pool!(pool, UInt8) + @test length(tp.vectors) == 2 + tp._am_peak_n_active = 0 # unused this period → trim the tail to 0 + @atomic pool._trim_requested = true + AAP._run_auto_manage!(pool) + @test length(tp.vectors) == 0 # the `others`-type slots were reclaimed + end end AAP.disable_auto_manage!() From e7126ba8ea7cf5fea678b716ee68dc8dbf28f9e9 Mon Sep 17 00:00:00 2001 From: Min-Gu Yoo Date: Tue, 30 Jun 2026 16:18:44 -0700 Subject: [PATCH 11/15] (release): bump version to v0.3.8 --- Project.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Project.toml b/Project.toml index 68f150df..bb54b525 100644 --- a/Project.toml +++ b/Project.toml @@ -1,7 +1,7 @@ name = "AdaptiveArrayPools" uuid = "4f381ef7-9af0-4cbe-99d4-cf36d7b0f233" authors = ["Min-Gu Yoo "] -version = "0.3.7" +version = "0.3.8" [deps] Preferences = "21216c6a-2e73-6563-6e65-726566657250" From cc12362084f9ede4ee0bc9638ca2febc12fb3935 Mon Sep 17 00:00:00 2001 From: Min-Gu Yoo Date: Tue, 30 Jun 2026 16:38:56 -0700 Subject: [PATCH 12/15] feat(auto_manage): descriptive config kwargs + LocalPreferences defaults Polish the public tuning API before it ships in v0.3.8: - Rename `enable_auto_manage!` keywords to self-documenting names: `interval` -> `compact_interval`, `factor` -> `compact_bloat_factor` (compact a slot at >= this x its live size), `shrink_to` -> `compact_target_ratio` (shrink to this x live size), `min_bytes` -> `compact_min_bytes`. `trim_interval` kept. - Drop the `active` keyword: auto-manage only ever runs at the depth-1 safepoint where `n_active == 0`, so compacting "active" slots is a no-op concept here (it was a `compact!`-only knob leaking into the auto API). `compact!`'s own kwargs are unchanged. - The keyword defaults now come from flat `auto_manage_*` Preferences (`auto_manage_compact_interval`, `auto_manage_trim_interval`, `auto_manage_compact_bloat_factor`, `auto_manage_compact_target_ratio`, `auto_manage_compact_min_bytes`), read once at load and coerced (`30` or `30.0` both fine). So the common case is to set them once in `LocalPreferences.toml`, no startup code; the keywords are the runtime override. `_AutoManageConfig` fields renamed to match. Updates the legacy (<1.12) shim signature, all CPU/Metal/CUDA tests, the docstring, and the Automatic Memory Management + Configuration docs. CPU auto suites green. --- docs/src/features/auto-manage.md | 28 +++++++++-- docs/src/features/configuration.md | 17 ++++++- src/auto_manage.jl | 77 +++++++++++++++++++---------- src/legacy/auto_manage.jl | 10 ++-- test/cuda/test_auto_integration.jl | 4 +- test/cuda/test_auto_trim.jl | 2 +- test/metal/test_auto_integration.jl | 4 +- test/metal/test_auto_trim.jl | 2 +- test/test_auto_integration.jl | 4 +- test/test_auto_manage.jl | 30 ++++++----- test/test_auto_manage_legacy.jl | 2 +- test/test_auto_trim.jl | 4 +- 12 files changed, 121 insertions(+), 63 deletions(-) diff --git a/docs/src/features/auto-manage.md b/docs/src/features/auto-manage.md index 1c02c2a2..796851c1 100644 --- a/docs/src/features/auto-manage.md +++ b/docs/src/features/auto-manage.md @@ -49,18 +49,36 @@ This means reclamation never runs mid-computation and never on the background th ## Controlling it +The usual way to configure auto-manage is **once, in `LocalPreferences.toml`** — the timer reads +these `auto_manage_*` keys at startup, so you never need to write any code: + +```toml +# LocalPreferences.toml (restart Julia to take effect) +[AdaptiveArrayPools] +auto_manage = true # master on/off (compile-time) +auto_manage_compact_interval = 30.0 # seconds — how often to auto-compact +auto_manage_trim_interval = 120.0 # seconds — how often to auto-trim (Inf disables it) +# advanced compaction tuning (rarely needed): +auto_manage_compact_bloat_factor = 10 # compact a slot at ≥ this × its live size +auto_manage_compact_target_ratio = 1.5 # shrink it down to this × live size +auto_manage_compact_min_bytes = 1048576 # skip if it would reclaim less +``` + +The same knobs are available at **runtime** (same names, minus the package-implied prefix) for +ad-hoc tuning or in startup code — these override the Preference defaults: + ```julia -# Re-tune the background timer (any subset of keywords) -enable_auto_manage!(; interval = 30.0, trim_interval = 120.0, - factor = 10, shrink_to = 1.5, min_bytes = 2^20) +enable_auto_manage!(; compact_interval = 30.0, trim_interval = 120.0, + compact_bloat_factor = 10, compact_target_ratio = 1.5, + compact_min_bytes = 2^20) enable_auto_manage!(; trim_interval = Inf) # compact-only: disable auto-trim -disable_auto_manage!() # stop the background timer +disable_auto_manage!() # stop the background timer (this session) auto_manage_enabled() # → Bool ``` `disable_auto_manage!()` only stops the timer for the current session; the feature re-arms on -the next Julia start. To turn it off permanently, use the Preference below. +the next Julia start. To turn it off permanently, set the `auto_manage` preference (below). ## Performance & turning it off diff --git a/docs/src/features/configuration.md b/docs/src/features/configuration.md index 4c7a54b2..ab505352 100644 --- a/docs/src/features/configuration.md +++ b/docs/src/features/configuration.md @@ -121,7 +121,22 @@ Master switch for the background memory reclamation engine (see [Automatic Memor auto_manage = false # compile the feature out; restart Julia to take effect ``` -Setting it to `false` dead-code-eliminates the `@with_pool` reclamation hook entirely, guaranteeing a zero-allocation hot path; `enable_auto_manage!` then becomes a no-op-with-warning and you reclaim memory only via manual `compact!` / `trim!`. The background timer is also tunable at runtime (no restart) with `enable_auto_manage!` / `disable_auto_manage!`. +Setting it to `false` (compile-time) dead-code-eliminates the `@with_pool` reclamation hook entirely, guaranteeing a zero-allocation hot path; `enable_auto_manage!` then becomes a no-op-with-warning and you reclaim memory only via manual `compact!` / `trim!`. + +### Tuning the timer (`auto_manage_*`) + +When `auto_manage` is on, the background timer's cadence and thresholds default from these flat keys (read once at startup; `enable_auto_manage!` overrides them at runtime). All optional: + +```toml +[AdaptiveArrayPools] +auto_manage_compact_interval = 30.0 # seconds — how often to auto-compact +auto_manage_trim_interval = 120.0 # seconds — how often to auto-trim (Inf disables) +auto_manage_compact_bloat_factor = 10 # compact a slot at ≥ this × its live size +auto_manage_compact_target_ratio = 1.5 # shrink it down to this × live size +auto_manage_compact_min_bytes = 1048576 # skip if it would reclaim less +``` + +See [Automatic Memory Management](@ref) for what each does. ## Summary diff --git a/src/auto_manage.jl b/src/auto_manage.jl index 8df0c4bd..ff220c36 100644 --- a/src/auto_manage.jl +++ b/src/auto_manage.jl @@ -40,17 +40,28 @@ const AUTO_MANAGE = @load_preference("auto_manage", true)::Bool const _AUTO_MANAGE_REGISTRY = WeakRef[] const _AUTO_MANAGE_LOCK = ReentrantLock() +# Preference-loaded defaults for the background timer, read once at load. Flat `auto_manage_*` +# keys under `[AdaptiveArrayPools]` (each with its own LocalPreferences default; see the +# Configuration docs); `enable_auto_manage!`'s keywords override them at runtime. Coerced +# (`Float64`/`round(Int,…)`) so a TOML `30` is accepted as readily as `30.0`. +const _DEFAULT_COMPACT_INTERVAL = Float64(@load_preference("auto_manage_compact_interval", 30.0)) +const _DEFAULT_TRIM_INTERVAL = Float64(@load_preference("auto_manage_trim_interval", 120.0)) +const _DEFAULT_COMPACT_BLOAT_FACTOR = Float64(@load_preference("auto_manage_compact_bloat_factor", 10.0)) +const _DEFAULT_COMPACT_TARGET_RATIO = Float64(@load_preference("auto_manage_compact_target_ratio", 1.5)) +const _DEFAULT_COMPACT_MIN_BYTES = round(Int, @load_preference("auto_manage_compact_min_bytes", 2^20)) + # Mutable global config, set by `enable_auto_manage!`, read by the sweep / `_run_auto_manage!`. -# Mirrors the `compact!` knobs (`factor`/`shrink_to` `Float64` for stable storage), plus the -# auto-trim cadence `trim_every_ticks` (auto-trim fires every N sweep ticks; 0 = never trim). +# The compaction knobs map to `compact!`'s `factor`/`shrink_to`/`min_bytes`; `trim_every_ticks` +# is the auto-trim cadence (auto-trim fires every N sweep ticks; 0 = never trim). mutable struct _AutoManageConfig - factor::Float64 - shrink_to::Float64 - min_bytes::Int - active::Bool + compact_bloat_factor::Float64 # → compact!'s `factor` (compact when capacity ≥ this × live size) + compact_target_ratio::Float64 # → compact!'s `shrink_to` (shrink to this × live size) + compact_min_bytes::Int # → compact!'s `min_bytes` (skip if reclaim < this) trim_every_ticks::Int end -const _AUTO_MANAGE_CONFIG = _AutoManageConfig(10.0, 1.5, 2^20, true, 0) +const _AUTO_MANAGE_CONFIG = _AutoManageConfig( + _DEFAULT_COMPACT_BLOAT_FACTOR, _DEFAULT_COMPACT_TARGET_RATIO, _DEFAULT_COMPACT_MIN_BYTES, 0, +) # Sweep tick counter driving the auto-trim cadence. Owned by the single Timer thread # (incremented/reset only inside the sweep), so a plain `Ref` is sufficient. @@ -151,12 +162,14 @@ function _run_auto_manage!(pool::AbstractArrayPool) end if (@atomic :monotonic pool._compact_requested) @atomic :monotonic pool._compact_requested = false - factor, shrink_to, min_bytes, active = lock(_AUTO_MANAGE_LOCK) do + factor, shrink_to, min_bytes = lock(_AUTO_MANAGE_LOCK) do cfg = _AUTO_MANAGE_CONFIG - (cfg.factor, cfg.shrink_to, cfg.min_bytes, cfg.active) + (cfg.compact_bloat_factor, cfg.compact_target_ratio, cfg.compact_min_bytes) end try - compact!(pool; factor, shrink_to, min_bytes, active) + # auto-manage runs at the depth-1 safepoint where `n_active == 0`, so `compact!`'s + # `active` is moot (every slot is inactive) — left at its default. + compact!(pool; factor, shrink_to, min_bytes) catch e @warn "auto-manage failed; skipping this cycle" exception = (e, catch_backtrace()) maxlog = 3 end @@ -222,15 +235,25 @@ function _stop_auto_manage_timer_unlocked!() end """ - enable_auto_manage!(; interval = 30.0, trim_interval = 120.0, factor = 10, - shrink_to = 1.5, min_bytes = 2^20, active = true) + enable_auto_manage!(; compact_interval = 30.0, trim_interval = 120.0, + compact_bloat_factor = 10, compact_target_ratio = 1.5, + compact_min_bytes = 2^20) + +Start (or restart) the background auto-manage `Timer`. Every `compact_interval` seconds it +flags every registered pool to **auto-compact** (shrink over-allocated backing buffers in +place); every `trim_interval` seconds it additionally flags an **auto-trim** (drop slots +beyond the recent working-set peak). Each pool services its pending flags at its next +`@with_pool` **entry** (the `_current_depth == 1` safepoint). A non-finite `trim_interval` +(e.g. `Inf`) disables auto-trim, leaving compaction only. + +The keyword **defaults come from the `auto_manage_*` Preferences** (see the Configuration +docs), so the usual workflow is to set them once in `LocalPreferences.toml` and never call +this — the keywords are the runtime override. -Start (or restart) the background auto-manage `Timer` with the given config. Every -`interval` seconds it flags every registered pool to **auto-compact** (shrink over-allocated -backing buffers in place); every `trim_interval` seconds it additionally flags an -**auto-trim** (drop slots beyond the recent working-set peak). Each pool services its pending -flags at its next `@with_pool` **entry** (the `_current_depth == 1` safepoint). A non-finite -`trim_interval` (e.g. `Inf`) disables auto-trim, leaving compaction only. +The compaction-tuning keywords are rarely needed and map to [`compact!`](@ref)'s own knobs: +`compact_bloat_factor` (→ `factor`) compacts a slot whose capacity is ≥ this × its live size; +`compact_target_ratio` (→ `shrink_to`) is the size it shrinks to (× live size, so `1.5` keeps +50 % headroom); `compact_min_bytes` (→ `min_bytes`) skips slots that would reclaim less. Requires the `auto_manage` compile-time Preference (`AUTO_MANAGE`) for the scope-entry hook and pool auto-registration. With the Preference **off**, the `@with_pool` hook is @@ -253,22 +276,24 @@ registering pools manually does not change that. Set the Preference and restart, See also [`disable_auto_manage!`](@ref), [`auto_manage_enabled`](@ref). """ function enable_auto_manage!(; - interval::Real = 30.0, factor::Real = 10, shrink_to::Real = 1.5, - min_bytes::Int = 2^20, active::Bool = true, trim_interval::Real = 120.0, + compact_interval::Real = _DEFAULT_COMPACT_INTERVAL, + trim_interval::Real = _DEFAULT_TRIM_INTERVAL, + compact_bloat_factor::Real = _DEFAULT_COMPACT_BLOAT_FACTOR, + compact_target_ratio::Real = _DEFAULT_COMPACT_TARGET_RATIO, + compact_min_bytes::Int = _DEFAULT_COMPACT_MIN_BYTES, ) AUTO_MANAGE || @warn "enable_auto_manage!: the `auto_manage` Preference is off — the `@with_pool` hook is compiled out, so the timer's flags are never serviced and no automatic management happens (registering pools does not change this). Set the Preference and restart to activate, or call `compact!`/`trim!` manually." maxlog = 1 # auto-trim cadence in sweep ticks; non-finite `trim_interval` (e.g. Inf) disables auto-trim. - trim_every = isfinite(trim_interval) ? max(1, round(Int, trim_interval / interval)) : 0 + trim_every = isfinite(trim_interval) ? max(1, round(Int, trim_interval / compact_interval)) : 0 lock(_AUTO_MANAGE_LOCK) do cfg = _AUTO_MANAGE_CONFIG - cfg.factor = Float64(factor) - cfg.shrink_to = Float64(shrink_to) - cfg.min_bytes = min_bytes - cfg.active = active + cfg.compact_bloat_factor = Float64(compact_bloat_factor) + cfg.compact_target_ratio = Float64(compact_target_ratio) + cfg.compact_min_bytes = compact_min_bytes cfg.trim_every_ticks = trim_every _AUTO_MANAGE_TICK[] = 0 # restart the trim cadence from this enable! end - _start_auto_manage_timer!(interval) + _start_auto_manage_timer!(compact_interval) return nothing end diff --git a/src/legacy/auto_manage.jl b/src/legacy/auto_manage.jl index 6aec7e83..5340cf02 100644 --- a/src/legacy/auto_manage.jl +++ b/src/legacy/auto_manage.jl @@ -19,16 +19,18 @@ register_auto_manage!(pool) = nothing @inline _maybe_auto_manage!(::Any) = nothing """ - enable_auto_manage!(; interval = 30.0, trim_interval = 120.0, factor = 10, - shrink_to = 1.5, min_bytes = 2^20, active = true) + enable_auto_manage!(; compact_interval = 30.0, trim_interval = 120.0, + compact_bloat_factor = 10, compact_target_ratio = 1.5, + compact_min_bytes = 2^20) No-op on Julia < 1.12 (the legacy pool architecture has no capacity management). Warns once. Upgrade to Julia 1.12+ for background auto-management. Accepts (and ignores) the same keywords as the 1.12+ method, so the public API is call-compatible across all supported Julia. """ function enable_auto_manage!(; - interval::Real = 30.0, trim_interval::Real = 120.0, factor::Real = 10, - shrink_to::Real = 1.5, min_bytes::Int = 2^20, active::Bool = true, + compact_interval::Real = 30.0, trim_interval::Real = 120.0, + compact_bloat_factor::Real = 10, compact_target_ratio::Real = 1.5, + compact_min_bytes::Int = 2^20, ) @warn "enable_auto_manage! is a no-op on Julia < 1.12 (legacy pool architecture). " * "Upgrade to Julia 1.12+ for background auto-management." maxlog = 1 diff --git a/test/cuda/test_auto_integration.jl b/test/cuda/test_auto_integration.jl index 948085ab..642fa6b9 100644 --- a/test/cuda/test_auto_integration.jl +++ b/test/cuda/test_auto_integration.jl @@ -68,7 +68,7 @@ AAP.disable_auto_manage!() empty!(pool) AAP.register_auto_manage!(pool) - AAP.enable_auto_manage!(interval = 0.01, trim_interval = 0.02) + AAP.enable_auto_manage!(compact_interval = 0.01, trim_interval = 0.02) test = run_sim_cuda!(N_STEPS) AAP.disable_auto_manage!() @@ -93,7 +93,7 @@ AAP.disable_auto_manage!() AAP.disable_auto_manage!(); _clear_registry!() pool = CuAdaptiveArrayPool{0}() AAP.register_auto_manage!(pool) - AAP.enable_auto_manage!(interval = 0.2, trim_interval = 0.6) # K = 3 + AAP.enable_auto_manage!(compact_interval = 0.2, trim_interval = 0.6) # K = 3 sleep(0.35) # ~1 tick @test (@atomic pool._compact_requested) == true diff --git a/test/cuda/test_auto_trim.jl b/test/cuda/test_auto_trim.jl index 0ef941ee..6511a930 100644 --- a/test/cuda/test_auto_trim.jl +++ b/test/cuda/test_auto_trim.jl @@ -142,7 +142,7 @@ AAP.disable_auto_manage!() # stop the __init__-started timer for deterministic p = CuAdaptiveArrayPool{0}() AAP.register_auto_manage!(p) @test _registry_len() == 1 - AAP.enable_auto_manage!(interval = 0.1, trim_interval = 0.1) # K=1: trim every sweep + AAP.enable_auto_manage!(compact_interval = 0.1, trim_interval = 0.1) # K=1: trim every sweep AAP._auto_manage_sweep!(nothing) # one sweep body → trim cadence hits @test (@atomic p._trim_requested) == true AAP.disable_auto_manage!() diff --git a/test/metal/test_auto_integration.jl b/test/metal/test_auto_integration.jl index 4f5638f7..531b047b 100644 --- a/test/metal/test_auto_integration.jl +++ b/test/metal/test_auto_integration.jl @@ -69,7 +69,7 @@ AAP.disable_auto_manage!() empty!(pool) AAP.register_auto_manage!(pool) - AAP.enable_auto_manage!(interval = 0.01, trim_interval = 0.02) + AAP.enable_auto_manage!(compact_interval = 0.01, trim_interval = 0.02) test = run_sim_metal!(N_STEPS) AAP.disable_auto_manage!() @@ -94,7 +94,7 @@ AAP.disable_auto_manage!() AAP.disable_auto_manage!(); _clear_registry!() pool = MetalAdaptiveArrayPool{0, METAL_STORAGE}() AAP.register_auto_manage!(pool) - AAP.enable_auto_manage!(interval = 0.2, trim_interval = 0.6) # K = 3 + AAP.enable_auto_manage!(compact_interval = 0.2, trim_interval = 0.6) # K = 3 sleep(0.35) # ~1 tick @test (@atomic pool._compact_requested) == true diff --git a/test/metal/test_auto_trim.jl b/test/metal/test_auto_trim.jl index 313c36b5..58fe9129 100644 --- a/test/metal/test_auto_trim.jl +++ b/test/metal/test_auto_trim.jl @@ -142,7 +142,7 @@ AAP.disable_auto_manage!() # stop the __init__-started timer for deterministic p = MetalAdaptiveArrayPool{0, METAL_STORAGE}() AAP.register_auto_manage!(p) @test _registry_len() == 1 - AAP.enable_auto_manage!(interval = 0.1, trim_interval = 0.1) # K=1: trim every sweep + AAP.enable_auto_manage!(compact_interval = 0.1, trim_interval = 0.1) # K=1: trim every sweep AAP._auto_manage_sweep!(nothing) # one sweep body → trim cadence hits @test (@atomic p._trim_requested) == true AAP.disable_auto_manage!() diff --git a/test/test_auto_integration.jl b/test/test_auto_integration.jl index ac964829..dfe4830c 100644 --- a/test/test_auto_integration.jl +++ b/test/test_auto_integration.jl @@ -84,7 +84,7 @@ AAP.disable_auto_manage!() # during the ~N_STEPS loop (interval 0.01 s ⇒ tens of sweeps over the run's wall-time). empty!(pool) AAP.register_auto_manage!(pool) - AAP.enable_auto_manage!(interval = 0.01, trim_interval = 0.02) + AAP.enable_auto_manage!(compact_interval = 0.01, trim_interval = 0.02) test = run_sim!(N_STEPS) AAP.disable_auto_manage!() @@ -114,7 +114,7 @@ AAP.disable_auto_manage!() pool = AdaptiveArrayPool{0}() AAP.register_auto_manage!(pool) # interval 0.2 s, trim every 0.6 s ⇒ K = 3 ticks. Timer first fires at t = interval. - AAP.enable_auto_manage!(interval = 0.2, trim_interval = 0.6) + AAP.enable_auto_manage!(compact_interval = 0.2, trim_interval = 0.6) sleep(0.35) # ~1 tick elapsed (tick @ 0.2 s) @test (@atomic pool._compact_requested) == true # compact flagged after 1 tick diff --git a/test/test_auto_manage.jl b/test/test_auto_manage.jl index 40b61b6d..e97373e0 100644 --- a/test/test_auto_manage.jl +++ b/test/test_auto_manage.jl @@ -87,10 +87,9 @@ AAP.disable_auto_manage!() # stop the __init__-started timer for deterministic end @testset "_run_auto_manage! uses the global config defaults" begin - @test AAP._AUTO_MANAGE_CONFIG.factor == 10 - @test AAP._AUTO_MANAGE_CONFIG.shrink_to == 1.5 - @test AAP._AUTO_MANAGE_CONFIG.min_bytes == 2^20 - @test AAP._AUTO_MANAGE_CONFIG.active == true + @test AAP._AUTO_MANAGE_CONFIG.compact_bloat_factor == 10 + @test AAP._AUTO_MANAGE_CONFIG.compact_target_ratio == 1.5 + @test AAP._AUTO_MANAGE_CONFIG.compact_min_bytes == 2^20 end # ── Timer lifecycle (enable/disable). AUTO_MANAGE is on → enable does NOT warn ─ @@ -101,23 +100,22 @@ AAP.disable_auto_manage!() # stop the __init__-started timer for deterministic AAP.register_auto_manage!(pool) AAP.enable_auto_manage!( - interval = 0.1, factor = 20, shrink_to = 2.0, min_bytes = 4096, active = false, + compact_interval = 0.1, compact_bloat_factor = 20, + compact_target_ratio = 2.0, compact_min_bytes = 4096, ) @test AAP.auto_manage_enabled() == true - @test AAP._AUTO_MANAGE_CONFIG.factor == 20.0 - @test AAP._AUTO_MANAGE_CONFIG.shrink_to == 2.0 - @test AAP._AUTO_MANAGE_CONFIG.min_bytes == 4096 - @test AAP._AUTO_MANAGE_CONFIG.active == false + @test AAP._AUTO_MANAGE_CONFIG.compact_bloat_factor == 20.0 + @test AAP._AUTO_MANAGE_CONFIG.compact_target_ratio == 2.0 + @test AAP._AUTO_MANAGE_CONFIG.compact_min_bytes == 4096 sleep(0.35) # ~3 ticks @ 0.1s @test (@atomic pool._compact_requested) == true AAP.disable_auto_manage!() @test AAP.auto_manage_enabled() == false - AAP._AUTO_MANAGE_CONFIG.factor = 10.0 # restore defaults - AAP._AUTO_MANAGE_CONFIG.shrink_to = 1.5 - AAP._AUTO_MANAGE_CONFIG.min_bytes = 2^20 - AAP._AUTO_MANAGE_CONFIG.active = true + AAP._AUTO_MANAGE_CONFIG.compact_bloat_factor = 10.0 # restore defaults + AAP._AUTO_MANAGE_CONFIG.compact_target_ratio = 1.5 + AAP._AUTO_MANAGE_CONFIG.compact_min_bytes = 2^20 _clear_registry!() end @@ -125,7 +123,7 @@ AAP.disable_auto_manage!() # stop the __init__-started timer for deterministic _clear_registry!() pool = AdaptiveArrayPool{0}() AAP.register_auto_manage!(pool) - AAP.enable_auto_manage!(interval = 0.1) + AAP.enable_auto_manage!(compact_interval = 0.1) AAP.disable_auto_manage!() @test AAP.auto_manage_enabled() == false @atomic pool._compact_requested = false @@ -138,9 +136,9 @@ AAP.disable_auto_manage!() # stop the __init__-started timer for deterministic @testset "enable! replaces an existing timer (closes the old one)" begin AAP.disable_auto_manage!() - AAP.enable_auto_manage!(interval = 0.1) + AAP.enable_auto_manage!(compact_interval = 0.1) t1 = AAP._AUTO_MANAGE_TIMER[] - AAP.enable_auto_manage!(interval = 0.2) + AAP.enable_auto_manage!(compact_interval = 0.2) t2 = AAP._AUTO_MANAGE_TIMER[] @test t1 !== t2 @test !isopen(t1) # old timer closed diff --git a/test/test_auto_manage_legacy.jl b/test/test_auto_manage_legacy.jl index 8c54c7db..0a46efa0 100644 --- a/test/test_auto_manage_legacy.jl +++ b/test/test_auto_manage_legacy.jl @@ -16,7 +16,7 @@ using AdaptiveArrayPools: get_task_local_pool @test auto_manage_enabled() == false # Accepts the full 1.12+ keyword set (incl. `trim_interval`) so the public API is # call-compatible across all supported Julia — passing it must not MethodError. - @test enable_auto_manage!(interval = 5.0, trim_interval = 60.0, factor = 20, active = false) === nothing + @test enable_auto_manage!(compact_interval = 5.0, trim_interval = 60.0, compact_bloat_factor = 20) === nothing @test disable_auto_manage!() === nothing @test auto_manage_enabled() == false diff --git a/test/test_auto_trim.jl b/test/test_auto_trim.jl index 86952faf..9352a1b7 100644 --- a/test/test_auto_trim.jl +++ b/test/test_auto_trim.jl @@ -129,7 +129,7 @@ AAP.disable_auto_manage!() pool = AdaptiveArrayPool{0}() AAP.register_auto_manage!(pool) # interval 0.1s, trim every 0.2s (K=2 ticks): within ~0.5s, _trim_requested fires - AAP.enable_auto_manage!(interval = 0.1, trim_interval = 0.2) + AAP.enable_auto_manage!(compact_interval = 0.1, trim_interval = 0.2) sleep(0.55) @test (@atomic pool._trim_requested) == true AAP.disable_auto_manage!() @@ -141,7 +141,7 @@ AAP.disable_auto_manage!() AAP.disable_auto_manage!() pool = AdaptiveArrayPool{0}() AAP.register_auto_manage!(pool) - AAP.enable_auto_manage!(interval = 0.1, trim_interval = Inf) + AAP.enable_auto_manage!(compact_interval = 0.1, trim_interval = Inf) sleep(0.35) @test (@atomic pool._trim_requested) == false # never flagged for trim AAP.disable_auto_manage!() From 341f905323d8d1290458923803ebe91944e7b32d Mon Sep 17 00:00:00 2001 From: Min-Gu Yoo Date: Tue, 30 Jun 2026 16:39:58 -0700 Subject: [PATCH 13/15] test(auto_manage): cover the auto_manage_* Preference defaults --- test/test_auto_manage.jl | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/test/test_auto_manage.jl b/test/test_auto_manage.jl index e97373e0..777de58a 100644 --- a/test/test_auto_manage.jl +++ b/test/test_auto_manage.jl @@ -92,6 +92,23 @@ AAP.disable_auto_manage!() # stop the __init__-started timer for deterministic @test AAP._AUTO_MANAGE_CONFIG.compact_min_bytes == 2^20 end + @testset "kwarg defaults come from the `auto_manage_*` Preferences (built-in fallbacks)" begin + # No `auto_manage_*` keys are set in the test env, so the Preference-loaded defaults are + # the built-in fallbacks. `enable_auto_manage!()` with no args must apply exactly these. + @test AAP._DEFAULT_COMPACT_INTERVAL == 30.0 + @test AAP._DEFAULT_TRIM_INTERVAL == 120.0 + @test AAP._DEFAULT_COMPACT_BLOAT_FACTOR == 10.0 + @test AAP._DEFAULT_COMPACT_TARGET_RATIO == 1.5 + @test AAP._DEFAULT_COMPACT_MIN_BYTES == 2^20 + AAP.disable_auto_manage!() + AAP._AUTO_MANAGE_CONFIG.compact_bloat_factor = -1.0 # perturb, then… + AAP.enable_auto_manage!() # …no-arg call restores the defaults + @test AAP._AUTO_MANAGE_CONFIG.compact_bloat_factor == 10.0 + @test AAP._AUTO_MANAGE_CONFIG.compact_target_ratio == 1.5 + @test AAP._AUTO_MANAGE_CONFIG.compact_min_bytes == 2^20 + AAP.disable_auto_manage!() + end + # ── Timer lifecycle (enable/disable). AUTO_MANAGE is on → enable does NOT warn ─ @testset "enable! sets config and starts a timer that flags registered pools" begin _clear_registry!() From 440ee3f82ff0130242efbb8b778de7e06e5090a8 Mon Sep 17 00:00:00 2001 From: Min-Gu Yoo Date: Tue, 30 Jun 2026 16:59:20 -0700 Subject: [PATCH 14/15] docs(auto-manage): add a before/after schematic for compact vs trim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an ASCII before/after diagram in "What it does" making the two actions visual: auto-compact shrinks the bloated buffer *within* one slot (a bar that gets shorter), auto-trim drops *whole* slots beyond the recent peak (boxes that disappear). Renders in Documenter's monospace code block — no Mermaid/plugin dependency. Also fixes the remaining old kwarg names on this page (interval→compact_interval, factor→compact_bloat_factor, min_bytes→compact_min_bytes) left over from the config rename. --- docs/src/features/auto-manage.md | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/docs/src/features/auto-manage.md b/docs/src/features/auto-manage.md index 796851c1..78c9bd24 100644 --- a/docs/src/features/auto-manage.md +++ b/docs/src/features/auto-manage.md @@ -13,17 +13,32 @@ whole point. But over a long-running program two kinds of slack accumulate: ## What it does -One background `Timer` drives two actions, on independent cadences: +One background `Timer` drives two complementary actions, on independent cadences: | Action | Reclaims | Default cadence | |-----------------|-----------------------------------------------------|-----------------| -| **auto-compact** | shrinks an over-allocated slot's backing **in place** | every `interval` (30 s) | -| **auto-trim** | drops slots beyond the **recent working-set peak** | every `trim_interval` (120 s) | +| **auto-compact** | shrinks an over-allocated slot's backing **in place** | every `compact_interval` (30 s) | +| **auto-trim** | drops **whole slots** beyond the recent working-set peak | every `trim_interval` (120 s) | -A slot is auto-compacted only when it is genuinely bloated — backing capacity `≥ factor ×` -its last use **and** the reclaim is `≥ min_bytes` (1 MiB) — so a steady-state pool is left -untouched. Auto-trim drops each type's slots down to the largest concurrency seen in the last -period (a type unused for the whole period trims to zero). +They act on different axes — one shrinks the buffer *within* a slot, the other drops *whole* slots: + +```text +auto-compact — shrink ONE slot's bloated backing buffer (the slot itself stays) + + before ▓▓▓░░░░░░░░░░░░░░░░░░░░░░░░ ▓ live data ░ wasted capacity (bloated) + after ▓▓▓░ reallocated to ~1.5× the live size; + arrays you're still holding follow the move + +auto-trim — drop whole SLOTS that fell out of the recent working set + + before [1][2][3][4][5] grew to a past peak of 5 concurrent arrays… + after [1][2] …recent peak is 2 → slots 3–5 freed entirely +``` + +A slot is auto-compacted only when it is genuinely bloated — backing capacity +`≥ compact_bloat_factor ×` its last use **and** the reclaim is `≥ compact_min_bytes` (1 MiB) — so a +steady-state pool is left untouched. Auto-trim drops each type's slots down to the largest +concurrency seen in the last period (a type unused for the whole period trims to zero). ## When (and where) the work happens From fae196ec212495d5b0f5c83beb399ab762242fa2 Mon Sep 17 00:00:00 2001 From: Min-Gu Yoo Date: Tue, 30 Jun 2026 17:03:42 -0700 Subject: [PATCH 15/15] docs(auto-manage): define compact/trim first, then auto-* as timed+smart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reframe "What it does": present `compact!` and `trim!` as the two primitives (with the before/after schematic), then `auto_manage` as "run those automatically on a timer, a bit smartly." The trim diagram now shows the ACTIVE slots (█ in use vs ░ idle-retained) so it's clear trim keeps what's in use and drops the idle tail. The auto-* table adds the smart part: auto-compact only touches bloated slots; auto-trim keeps the recent working-set PEAK (not the instantaneous count) so a brief widening isn't thrashed. --- docs/src/features/auto-manage.md | 35 +++++++++++++++----------------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/docs/src/features/auto-manage.md b/docs/src/features/auto-manage.md index 78c9bd24..543f2662 100644 --- a/docs/src/features/auto-manage.md +++ b/docs/src/features/auto-manage.md @@ -13,32 +13,29 @@ whole point. But over a long-running program two kinds of slack accumulate: ## What it does -One background `Timer` drives two complementary actions, on independent cadences: - -| Action | Reclaims | Default cadence | -|-----------------|-----------------------------------------------------|-----------------| -| **auto-compact** | shrinks an over-allocated slot's backing **in place** | every `compact_interval` (30 s) | -| **auto-trim** | drops **whole slots** beyond the recent working-set peak | every `trim_interval` (120 s) | - -They act on different axes — one shrinks the buffer *within* a slot, the other drops *whole* slots: +The reclaiming is done by two primitives — `compact!` and `trim!` — and `auto_manage` simply +runs them for you on a timer. First, what each one does to a pool: ```text -auto-compact — shrink ONE slot's bloated backing buffer (the slot itself stays) +compact — shrink ONE slot's over-grown backing buffer down to what it actually holds - before ▓▓▓░░░░░░░░░░░░░░░░░░░░░░░░ ▓ live data ░ wasted capacity (bloated) - after ▓▓▓░ reallocated to ~1.5× the live size; - arrays you're still holding follow the move + before ███░░░░░░░░░░░░░░░░░░░░░░░░ █ live data ░ over-allocated slack + after ███░ buffer reallocated to ~1.5× the live data + (the slot stays; arrays you hold follow the move) -auto-trim — drop whole SLOTS that fell out of the recent working set +trim — free the retained slots that sit BEYOND the ones currently in use - before [1][2][3][4][5] grew to a past peak of 5 concurrent arrays… - after [1][2] …recent peak is 2 → slots 3–5 freed entirely + before [█][█][░][░][░] █ in use (2) ░ idle, still retained (3) + after [█][█] the idle tail is freed; the in-use slots stay ``` -A slot is auto-compacted only when it is genuinely bloated — backing capacity -`≥ compact_bloat_factor ×` its last use **and** the reclaim is `≥ compact_min_bytes` (1 MiB) — so a -steady-state pool is left untouched. Auto-trim drops each type's slots down to the largest -concurrency seen in the last period (a type unused for the whole period trims to zero). +So `compact` works *within* a slot (a smaller buffer); `trim` drops *whole* slots. `auto_manage` +runs both on a background `Timer` — a little more smartly than calling them by hand: + +| Runs | Action | …automatically, but only where it pays off | +|------|--------|--------------------------------------------| +| every `compact_interval` (**30 s**) | **auto-compact** | `compact!` restricted to genuinely bloated slots: capacity `≥ compact_bloat_factor ×` the live size **and** `≥ compact_min_bytes` (1 MiB) to reclaim — a steady-state pool is left untouched | +| every `trim_interval` (**120 s**) | **auto-trim** | `trim!` down to the **recent working-set peak** (the widest concurrency seen since the last trim, not just the instantaneous in-use count) — so a pool that briefly widens isn't thrashed; a type unused all period trims to zero | ## When (and where) the work happens