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" 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..543f2662 --- /dev/null +++ b/docs/src/features/auto-manage.md @@ -0,0 +1,126 @@ +# 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 + +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 +compact — shrink ONE slot's over-grown backing buffer down to what it actually holds + + before ███░░░░░░░░░░░░░░░░░░░░░░░░ █ live data ░ over-allocated slack + after ███░ buffer reallocated to ~1.5× the live data + (the slot stays; arrays you hold follow the move) + +trim — free the retained slots that sit BEYOND the ones currently in use + + before [█][█][░][░][░] █ in use (2) ░ idle, still retained (3) + after [█][█] the idle tail is freed; the in-use slots stay +``` + +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 + +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 + +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 +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 (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, set the `auto_manage` 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..ab505352 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,33 @@ 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` (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 | Setting | Scope | Restart? | Priority | Affects | @@ -118,4 +145,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. | --- diff --git a/ext/AdaptiveArrayPoolsCUDAExt/AdaptiveArrayPoolsCUDAExt.jl b/ext/AdaptiveArrayPoolsCUDAExt/AdaptiveArrayPoolsCUDAExt.jl index eaa305c6..f8602e9b 100644 --- a/ext/AdaptiveArrayPoolsCUDAExt/AdaptiveArrayPoolsCUDAExt.jl +++ b/ext/AdaptiveArrayPoolsCUDAExt/AdaptiveArrayPoolsCUDAExt.jl @@ -33,8 +33,8 @@ using CUDA # State management (checkpoint!, rewind!, reset!, empty!) include("state.jl") - # Auto-compact: service the timer-flagged request at `@with_pool :cuda` entry - include("auto_compact.jl") + # Auto-manage: service the timer-flagged request at `@with_pool :cuda` entry + include("auto_manage.jl") # Safety: poisoning, escape detection, borrow tracking include("debug.jl") diff --git a/ext/AdaptiveArrayPoolsCUDAExt/acquire.jl b/ext/AdaptiveArrayPoolsCUDAExt/acquire.jl index 0ed23880..85108e2c 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._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) @@ -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._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/auto_compact.jl b/ext/AdaptiveArrayPoolsCUDAExt/auto_manage.jl similarity index 73% rename from ext/AdaptiveArrayPoolsCUDAExt/auto_compact.jl rename to ext/AdaptiveArrayPoolsCUDAExt/auto_manage.jl index 7ddd0649..02e9ae5b 100644 --- a/ext/AdaptiveArrayPoolsCUDAExt/auto_compact.jl +++ b/ext/AdaptiveArrayPoolsCUDAExt/auto_manage.jl @@ -1,8 +1,8 @@ # ============================================================================== -# 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_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/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/AdaptiveArrayPoolsCUDAExt/task_local_pool.jl b/ext/AdaptiveArrayPoolsCUDAExt/task_local_pool.jl index e81b79df..615cb092 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-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_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..e885b7ea 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). + _am_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, # _am_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-compact 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/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/acquire.jl b/ext/AdaptiveArrayPoolsMetalExt/acquire.jl index 2c2a0560..b4f12d6b 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._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) @@ -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._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/auto_compact.jl b/ext/AdaptiveArrayPoolsMetalExt/auto_manage.jl similarity index 72% rename from ext/AdaptiveArrayPoolsMetalExt/auto_compact.jl rename to ext/AdaptiveArrayPoolsMetalExt/auto_manage.jl index 8c9b9d18..7738df03 100644 --- a/ext/AdaptiveArrayPoolsMetalExt/auto_compact.jl +++ b/ext/AdaptiveArrayPoolsMetalExt/auto_manage.jl @@ -1,8 +1,8 @@ # ============================================================================== -# 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_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/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/ext/AdaptiveArrayPoolsMetalExt/task_local_pool.jl b/ext/AdaptiveArrayPoolsMetalExt/task_local_pool.jl index f77984f0..eb0a8d89 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-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_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..bc09c3c0 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). + _am_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, # _am_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-compact request + false, # _compact_requested: no pending compact request + false, # _trim_requested: no pending auto-trim 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/acquire.jl b/src/acquire.jl index a02c03e0..8fd9ecd8 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._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 @@ -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._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_compact.jl b/src/auto_compact.jl deleted file mode 100644 index 25da8e8c..00000000 --- a/src/auto_compact.jl +++ /dev/null @@ -1,241 +0,0 @@ -# ============================================================================== -# Auto-Compact: timer-driven background capacity compaction. -# Design: docs/plans/DESIGN_auto_compact.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 -# 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!`. -# ============================================================================== - -using Preferences: @load_preference - -""" - AUTO_COMPACT::Bool - -Compile-time master switch (Tier 1) for auto-compaction. 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 -dead-code-eliminated, restoring zero hot-path cost: - -```toml -[AdaptiveArrayPools] -auto_compact = false -``` - -`enable_auto_compact!` / `disable_auto_compact!` control the background Timer at runtime -(Tier 2). -""" -const AUTO_COMPACT = @load_preference("auto_compact", true)::Bool - -# Registry of pools eligible for auto-compaction. `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() - -# Mutable global config, set by `enable_auto_compact!`, read by `_run_auto_compact!`. -# Mirrors the `compact!` knobs; `factor`/`shrink_to` are `Float64` for stable storage. -mutable struct _AutoCompactConfig - factor::Float64 - shrink_to::Float64 - min_bytes::Int - active::Bool -end -const _AUTO_COMPACT_CONFIG = _AutoCompactConfig(10.0, 1.5, 2^20, true) - -""" - register_auto_compact!(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 -(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. - -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) - # 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) - 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 - i = 1 - while i <= length(_AUTO_COMPACT_REGISTRY) - pool = _AUTO_COMPACT_REGISTRY[i].value - if pool === nothing - _AUTO_COMPACT_REGISTRY[i] = _AUTO_COMPACT_REGISTRY[end] - pop!(_AUTO_COMPACT_REGISTRY) # dead → swap-remove - else - p = pool::AbstractArrayPool # CPU or any GPU backend pool - @atomic :monotonic p._compact_requested = true # request compaction - i += 1 - end - end - end - return nothing -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 -# 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) - @atomic :monotonic pool._compact_requested = false - factor, shrink_to, min_bytes, active = lock(_AUTO_COMPACT_LOCK) do - cfg = _AUTO_COMPACT_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 - 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 -# 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 -# `@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 -# 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) - if (@atomic :monotonic pool._compact_requested) && pool._current_depth == 1 - _run_auto_compact!(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) - -# 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) - try - _auto_compact_sweep!(timer) - catch e - @warn "auto-compact 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) - end - return nothing -end - -_stop_auto_compact_timer!() = (lock(_stop_auto_compact_timer_unlocked!, _AUTO_COMPACT_LOCK); nothing) - -function _stop_auto_compact_timer_unlocked!() - t = _AUTO_COMPACT_TIMER[] - if t !== nothing - close(t) - _AUTO_COMPACT_TIMER[] = nothing - end - return nothing -end - -""" - enable_auto_compact!(; 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 -`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 -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 -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-compacted); `register_auto_compact!` 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 - guaranteed-zero-alloc hot path. - -See also [`disable_auto_compact!`](@ref), [`auto_compact_enabled`](@ref). -""" -function enable_auto_compact!(; - 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 - cfg.factor = Float64(factor) - cfg.shrink_to = Float64(shrink_to) - cfg.min_bytes = min_bytes - cfg.active = active - end - _start_auto_compact_timer!(interval) - return nothing -end - -""" - disable_auto_compact!() - -Stop the background auto-compact `Timer`. Idempotent. -""" -disable_auto_compact!() = _stop_auto_compact_timer!() - -""" - auto_compact_enabled() -> Bool - -Whether the background auto-compact `Timer` is currently running. -""" -auto_compact_enabled() = _AUTO_COMPACT_TIMER[] !== nothing - -# Auto-start the background timer when AUTO_COMPACT 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!) - end - return nothing -end diff --git a/src/auto_manage.jl b/src/auto_manage.jl new file mode 100644 index 00000000..ff220c36 --- /dev/null +++ b/src/auto_manage.jl @@ -0,0 +1,325 @@ +# ============================================================================== +# Auto-Manage: timer-driven background memory management (compaction + trim). +# Design: docs/plans/DESIGN_auto_manage.md +# +# 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 +# (~0.6 ns/entry); set the Preference false to compile it OUT (full DCE → zero cost). +# Tier 2 (runtime): `enable_auto_manage!` / `disable_auto_manage!`. +# ============================================================================== + +using Preferences: @load_preference + +""" + AUTO_MANAGE::Bool + +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 +dead-code-eliminated, restoring zero hot-path cost: + +```toml +[AdaptiveArrayPools] +auto_manage = false +``` + +`enable_auto_manage!` / `disable_auto_manage!` control the background Timer at runtime +(Tier 2). +""" +const AUTO_MANAGE = @load_preference("auto_manage", true)::Bool + +# 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() + +# 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!`. +# 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 + 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( + _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. +const _AUTO_MANAGE_TICK = Ref{Int}(0) + +""" + register_auto_manage!(pool) + +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. + +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_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_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_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 + if pool === nothing + _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 (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 + end + return nothing +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_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. +# Auto-trim one pool: drop each type's cold slot tail down to its recent working-set peak +# (`_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._am_peak_n_active) + tp._am_peak_n_active = 0 + end + for tp in values(pool.others) + _trim_to!(tp, tp._am_peak_n_active) + tp._am_peak_n_active = 0 + end + return nothing +end + +function _run_auto_manage!(pool::AbstractArrayPool) + # 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 + if (@atomic :monotonic pool._compact_requested) + @atomic :monotonic pool._compact_requested = false + factor, shrink_to, min_bytes = lock(_AUTO_MANAGE_LOCK) do + cfg = _AUTO_MANAGE_CONFIG + (cfg.compact_bloat_factor, cfg.compact_target_ratio, cfg.compact_min_bytes) + end + try + # 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 + end + return nothing +end + +# Scope-ENTRY hook body, generated before the checkpoint in every `@with_pool` (gated by +# 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_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_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_manage!(::Any) = nothing +@inline function _maybe_auto_manage!(pool::AdaptiveArrayPool) + if (@atomic :monotonic pool._compact_requested) && pool._current_depth == 1 + _run_auto_manage!(pool) + end + return nothing +end + +# ── Timer lifecycle (Tier 2 runtime control) ───────────────────────────────── + +# 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_manage_sweep!(timer) + try + _auto_manage_sweep!(timer) + catch e + @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_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_manage_timer!() = (lock(_stop_auto_manage_timer_unlocked!, _AUTO_MANAGE_LOCK); nothing) + +function _stop_auto_manage_timer_unlocked!() + t = _AUTO_MANAGE_TIMER[] + if t !== nothing + close(t) + _AUTO_MANAGE_TIMER[] = nothing + end + return nothing +end + +""" + 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. + +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 +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 +registering pools manually does not change that. Set the Preference and restart, or call +`compact!` yourself. + +!!! 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). +""" +function enable_auto_manage!(; + 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 / compact_interval)) : 0 + lock(_AUTO_MANAGE_LOCK) do + cfg = _AUTO_MANAGE_CONFIG + 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!(compact_interval) + return nothing +end + +""" + disable_auto_manage!() + +Stop the background auto-manage `Timer`. Idempotent. +""" +disable_auto_manage!() = _stop_auto_manage_timer!() + +""" + auto_manage_enabled() -> Bool + +Whether the background auto-manage `Timer` is currently running. +""" +auto_manage_enabled() = _AUTO_MANAGE_TIMER[] !== nothing + +# 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_MANAGE && ccall(:jl_generating_output, Cint, ()) == 0 + enable_auto_manage!() + atexit(disable_auto_manage!) + end + return nothing +end diff --git a/src/bitarray.jl b/src/bitarray.jl index 89776e7a..7c6a1739 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._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/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..5340cf02 --- /dev/null +++ b/src/legacy/auto_manage.jl @@ -0,0 +1,52 @@ +# ============================================================================== +# 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-management. +# ============================================================================== + +# 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!(; 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!(; + 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 + 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-management). See [`enable_auto_manage!`](@ref). +""" +auto_manage_enabled() = false diff --git a/src/macros.jl b/src/macros.jl index 9a49e836..8f331c68 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 @@ -1592,15 +1592,15 @@ 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`. -# 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 +# 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 # 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/state.jl b/src/state.jl index e9c0fbfe..5ddf3866 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 @@ -887,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/src/task_local_pool.jl b/src/task_local_pool.jl index 475f8e86..5310da5f 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-management (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..9f159aaa 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). + _am_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, # _am_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) --- + _am_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, # _am_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-compact 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-compact 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/cuda/runtests.jl b/test/cuda/runtests.jl index a7d997fd..17dc64d6 100644 --- a/test/cuda/runtests.jl +++ b/test/cuda/runtests.jl @@ -51,6 +51,8 @@ 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") + 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..642fa6b9 --- /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!(compact_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._am_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!(compact_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/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..e041bb90 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-managed, 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/cuda/test_auto_trim.jl b/test/cuda/test_auto_trim.jl new file mode 100644 index 00000000..6511a930 --- /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 +# `_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. +# +# 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._am_peak_n_active (default 0) + pool._trim_requested (default false)" begin + pool = CuAdaptiveArrayPool{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 _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._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._am_peak_n_active = 0 + checkpoint!(pool); acquire!(pool, Float32, 4); rewind!(pool) + @test pool.float32._am_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._am_peak_n_active = 0 # start a fresh observation period + checkpoint!(pool); acquire!(pool, Float32, 100); rewind!(pool) # narrowed: peak = 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._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._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) + @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._am_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._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 ────── + # 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._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 + @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!(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!() + _clear_registry!() + end +end + +AAP.disable_auto_manage!() # leave the timer stopped for later CUDA test files 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/runtests.jl b/test/metal/runtests.jl index 4812ae95..3ee5fbe6 100644 --- a/test/metal/runtests.jl +++ b/test/metal/runtests.jl @@ -53,6 +53,8 @@ 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") + 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..531b047b --- /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!(compact_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._am_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!(compact_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/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..4a2444be 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-managed, 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/metal/test_auto_trim.jl b/test/metal/test_auto_trim.jl new file mode 100644 index 00000000..58fe9129 --- /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 +# `_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. +# +# 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._am_peak_n_active (default 0) + pool._trim_requested (default false)" begin + pool = MetalAdaptiveArrayPool{0, METAL_STORAGE}() + @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 _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._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._am_peak_n_active = 0 + checkpoint!(pool); acquire!(pool, Float32, 4); rewind!(pool) + @test pool.float32._am_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._am_peak_n_active = 0 # start a fresh observation period + checkpoint!(pool); acquire!(pool, Float32, 100); rewind!(pool) # narrowed: peak = 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._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._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) + @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._am_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._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 ────── + # 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._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 + @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!(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!() + _clear_registry!() + end +end + +AAP.disable_auto_manage!() # leave the timer stopped for later Metal test files 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/runtests.jl b/test/runtests.jl index 8ec5c0b1..49c602af 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-management 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,9 @@ 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_auto_trim.jl") + include("test_auto_integration.jl") include("test_multidimensional.jl") include("test_macros.jl") include("test_task_local_pool.jl") @@ -61,7 +63,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_integration.jl b/test/test_auto_integration.jl new file mode 100644 index 00000000..dfe4830c --- /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!(compact_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._am_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!(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 + @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 diff --git a/test/test_auto_compact.jl b/test/test_auto_manage.jl similarity index 60% rename from test/test_auto_compact.jl rename to test/test_auto_manage.jl index 6305b9ac..777de58a 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,164 @@ 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.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_COMPACT is on → enable does NOT warn ─ + @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!() - 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!( - interval = 0.1, factor = 20, shrink_to = 2.0, min_bytes = 4096, active = false, + AAP.enable_auto_manage!( + compact_interval = 0.1, compact_bloat_factor = 20, + compact_target_ratio = 2.0, compact_min_bytes = 4096, ) - @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.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_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.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 @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!(compact_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!(compact_interval = 0.1) + t1 = AAP._AUTO_MANAGE_TIMER[] + AAP.enable_auto_manage!(compact_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 + @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_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 +228,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 +252,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 +276,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-managed, no manual compact! empty!(pool) _clear_registry!() end @@ -253,7 +286,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 +327,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 +354,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..0a46efa0 --- /dev/null +++ b/test/test_auto_manage_legacy.jl @@ -0,0 +1,34 @@ +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 + # 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!(compact_interval = 5.0, trim_interval = 60.0, compact_bloat_factor = 20) === 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-management). + 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_trim.jl b/test/test_auto_trim.jl new file mode 100644 index 00000000..9352a1b7 --- /dev/null +++ b/test/test_auto_trim.jl @@ -0,0 +1,165 @@ +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 +# `_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. +# +# 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._am_peak_n_active (default 0) + pool._trim_requested (default false)" begin + pool = AdaptiveArrayPool{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 _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._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._am_peak_n_active = 0 + checkpoint!(pool); acquire!(pool, Float64, 4); rewind!(pool) + @test pool.float64._am_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._am_peak_n_active = 0 # start a fresh observation period + checkpoint!(pool); acquire!(pool, Float64, 100); rewind!(pool) # narrowed: peak = 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._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._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) + @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._am_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._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 + 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._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 + @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!(compact_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!(compact_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 + + @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!() 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 —