diff --git a/claudedocs/mutation_safety_review.md b/claudedocs/mutation_safety_review.md new file mode 100644 index 0000000..3216740 --- /dev/null +++ b/claudedocs/mutation_safety_review.md @@ -0,0 +1,366 @@ +# Pool-Backed Array Structural Mutation: Safety Analysis & Review Request + +## Decision (Resolved) + +**Runtime: warning (`@warn ... maxlog=1`).** Compile-time: hard error (`PoolMutationError`). + +Rationale: `resize!/push!/pop!` on pool-backed arrays does NOT cause data corruption +or undefined behavior. The pool is self-healing — `acquire!` at the same slot always +resets the wrapper's memory reference. The only consequences are: +- Pooling benefits (zero-alloc reuse) may be lost +- Temporary extra memory retention (GPU: VRAM) until next acquire at the same slot + +This is analogous to standard Julia aliasing behavior, not a safety violation. +Runtime error would be disproportionate; warning with `maxlog=1` is appropriate. + +--- + +## Architecture Overview + +AdaptiveArrayPools.jl is a Julia array pooling library. The core idea: + +``` +checkpoint!(pool) # save current allocation state +v = acquire!(pool, T, n) # get a T[n] array from pool (zero-alloc on reuse) +# ... use v ... +rewind!(pool) # release all arrays acquired since checkpoint +``` + +### How arrays are stored + +``` +pool.float64 :: TypedPool{Float64} + .vectors :: Vector{Vector{Float64}} # backing vectors (the actual storage) + .arr_wrappers :: Vector{Union{Nothing, Vector{Any}}} # cached N-D Array wrappers + .n_active :: Int # how many slots are currently "borrowed" +``` + +### What `acquire!` returns + +`acquire!` does NOT return `tp.vectors[slot]` directly. +It returns a **cached wrapper** that shares the same underlying memory: + +```julia +# CPU acquire path (simplified from src/acquire.jl) +function get_array!(tp, dims::NTuple{N,Int}) + slot = _claim_slot!(tp, total_len) + vec = tp.vectors[slot] # backing vector + + # Cache hit: reuse existing wrapper + wrapper = arr_wrappers[N][slot] + if wrapper !== nothing + arr = wrapper::Array{T,N} + setfield!(arr, :ref, getfield(vec, :ref)) # point to backing vec's Memory + setfield!(arr, :size, dims) # set dimensions + return arr # WRAPPER returned, not vec + end + + # Cache miss: create new wrapper sharing vec's memory + arr = Array{T,N}(undef, zeros...) + setfield!(arr, :ref, getfield(vec, :ref)) + setfield!(arr, :size, dims) + _store_arr_wrapper!(tp, N, slot, arr) + return arr +end +``` + +**Key invariant**: `wrapper.ref.mem === vec.ref.mem` — they share the same `Memory{T}` object. + +On GPU (CUDA/Metal), the pattern is analogous but uses `DataRef` instead of `MemoryRef`: +- CUDA: `wrapper.data.rc === vec.data.rc` +- Metal: `getfield(wrapper, :data).rc === getfield(vec, :data).rc` + +### What `rewind!` does + +```julia +# Invalidation at rewind time (src/state.jl, simplified) +function _invalidate_released_slots!(tp::TypedPool{T}, old_n_active, S) + new_n = tp.n_active + + # Step 1: Check for structural mutation (BEFORE any invalidation) + _check_wrapper_mutation!(tp, new_n, old_n_active) + + # Step 2: Poison released vectors (NaN for floats, typemax for ints) + _poison_released_vectors!(tp, old_n_active) + + # Step 3: Resize backing vectors to length 0 + for i in (new_n+1):old_n_active + resize!(tp.vectors[i], 0) + end + + # Step 4: Zero out cached wrapper dimensions + for wrapper in released_wrappers + setfield!(wrapper::Array, :size, (0, 0, ...)) + end +end +``` + +--- + +## The Question: What happens when user does `resize!(wrapper, 100_000)`? + +The wrapper is the object returned by `acquire!`. The backing vector is `tp.vectors[slot]`. + +### Case A: `resize!` beyond Memory capacity (reallocation) + +``` +Before: + tp.vectors[slot] → Memory_A (backing, pool-managed) + arr_wrappers[1][slot] → Memory_A (wrapper, returned to user) + +After resize!(wrapper, 100_000): + tp.vectors[slot] → Memory_A (unchanged — pool doesn't know about resize) + arr_wrappers[1][slot] → Memory_B (NEW memory allocated by Julia's resize!) +``` + +The wrapper now has its own independent memory. **No memory overlap. No data corruption.** + +### Case B: `resize!` within Memory capacity (no reallocation) + +Julia may change only the logical length without reallocating. In this case: +- Same `Memory{T}` object, wrapper just has different logical bounds +- Elements beyond original length are uninitialized but within valid allocated memory +- No corruption, but reading beyond `vec_len` returns uninitialized data + +### Case C: `push!` that triggers reallocation + +Same as Case A. After enough pushes, Julia allocates new Memory. +The wrapper becomes independent from the backing vector. + +--- + +## Self-Healing Mechanism + +**The pool automatically recovers on the next `acquire!` at the same slot.** + +### CPU path (src/acquire.jl:244-251) + +```julia +# Cache hit: ALWAYS overwrites wrapper's MemoryRef +arr = wrapper::Array{T, N} +setfield!(arr, :ref, getfield(vec, :ref)) # ← forces wrapper back to backing vec +setfield!(arr, :size, dims) +return arr +``` + +CPU is unconditional — `setfield!(:ref)` runs every cache hit, regardless of mutation. +This means even if the wrapper pointed to Memory_B, next acquire resets it to Memory_A. + +### GPU path (ext/.../acquire.jl) + +```julia +# Cache hit: conditional update +if getfield(mtl, :data).rc !== getfield(vec, :data).rc + _update_metal_wrapper_data!(mtl, vec) # unsafe_free! old, copy new DataRef +end +setfield!(mtl, :dims, dims) +return mtl +``` + +GPU also self-heals, but conditionally (only when DataRef diverged). +`_update_metal_wrapper_data!` properly decrements the old refcount and increments the new. + +--- + +## Potential Concerns & Edge Cases + +### 1. GPU VRAM temporary leak + +After `resize!(wrapper, 100_000)` on GPU: +- The cached wrapper in `arr_wrappers[N][slot]` holds a DataRef to the resized GPU buffer +- This DataRef keeps the GPU memory alive until the next `acquire!` at the same slot +- Between the resize and next acquire, VRAM is "leaked" (held by cached wrapper) + +**Severity**: Low. Temporary, self-resolving. Proportional to the resized size. +**Mitigation**: Next acquire at same slot calls `_update_wrapper_data!` which frees it. + +### 2. Invalidation path: dims zeroing on already-diverged wrapper + +At rewind, `_invalidate_released_slots!` does: +```julia +setfield!(wrapper::Array, :size, (0, 0, ...)) +``` + +If the wrapper was resized and now points to Memory_B: +- This zeros the wrapper's dims but doesn't affect Memory_B's data +- Memory_B is still referenced by the wrapper's MemoryRef/DataRef +- On CPU: Memory_B will be GC'd when wrapper ref is overwritten at next acquire +- On GPU: Same, but `_update_wrapper_data!` explicitly calls `unsafe_free!` + +**No issue here** — dims zeroing is purely logical, doesn't affect memory management. + +### 3. Concurrent/task-local pools + +If multiple tasks share a pool (not recommended but possible): +- Task A resizes wrapper → wrapper diverges +- Task B rewinds → mutation check fires, warns +- No data corruption because wrapper and backing are independent + +**No additional risk** from mutation in concurrent scenarios. + +### 4. resize! WITHIN capacity but beyond backing vector length + +```julia +v = acquire!(pool, Float64, 10) # wrapper and vec both length 10 +# Memory capacity might be >> 10 (from a previous larger acquire) +resize!(v, 50) # within capacity, no reallocation +``` + +Now wrapper has length 50 from the same Memory, but backing vec still has length 10. +Elements 11-50 of the wrapper are valid memory (within allocated Memory) but were not +initialized by the pool. They contain whatever was in Memory from the previous cycle. + +**Severity**: Low. Not UB — Julia Memory objects are fully allocated. Data is unpredictable +but not dangerous (it's old pool data, not out-of-bounds memory). + +### 5. Could `resize!` on wrapper corrupt the backing vector? + +**No.** `resize!` on wrapper can only: +- Reallocate wrapper's Memory (Case A) → backing vector unaffected +- Change wrapper's logical bounds within existing Memory (Case B) → backing vector's + MemoryRef still points to the same range + +The backing vector's MemoryRef is a separate Julia object. `resize!` on the wrapper +cannot modify `tp.vectors[slot]`'s MemoryRef fields. + +### 6. Could `push!` on wrapper corrupt the backing vector? + +**No.** Same reasoning. `push!` may trigger `resize!` internally (Case A) or grow +within capacity (Case B). Neither modifies the backing vector. + +### 7. What about `pop!`, `deleteat!`, `splice!`, `empty!`? + +These only modify the wrapper (shrink or rearrange elements). They cannot affect +the backing vector because the backing vector is a separate Julia object with its +own MemoryRef. Even in the shared-Memory case (Case B), these operations only +change the wrapper's logical bounds, not the backing vector's. + +### 8. What if user does `resize!(wrapper, 0)` then `resize!(wrapper, original_size)`? + +- First resize to 0: no reallocation (within capacity), just dims change +- Second resize back: no reallocation (still within capacity), dims restored +- Backing vector is unaffected throughout +- At rewind: no mutation detected (same Memory, same length as backing) + +This is actually a **non-issue** — pool wouldn't even warn about it. + +--- + +## Summary: Risk Assessment + +| Scenario | Data Corruption | UB | VRAM Leak | Functional Impact | +|----------|:-:|:-:|:-:|:--| +| resize! beyond capacity | No | No | Temporary (GPU) | Pool self-heals on next acquire | +| resize! within capacity | No | No | No | Wrapper reads stale pool data | +| push! with reallocation | No | No | Temporary (GPU) | Pool self-heals on next acquire | +| pop!/deleteat!/splice! | No | No | No | None — wrapper shrinks | +| Concurrent mutation | No | No | Temporary (GPU) | Warning at rewind | + +**No scenario causes data corruption, undefined behavior, or permanent resource leak.** + +The pool is **self-healing by design**: the acquire path always resets the wrapper's +memory reference to the backing vector, regardless of what happened to the wrapper +between acquire and rewind. + +--- + +## Current Implementation + +### Compile-time (macro expansion) + +```julia +# src/macros.jl — PoolMutationError +@with_pool pool begin + v = acquire!(pool, Float64, 10) + resize!(v, 100) # ← caught at macro expansion → PoolMutationError (hard error) +end +``` + +This is a **hard error** at compile time. Zero runtime cost. Catches definite +anti-patterns before code runs. User can `copy()` to opt out. + +### Runtime (rewind time) + +```julia +# src/debug.jl — _check_wrapper_mutation! +# Called from _invalidate_released_slots! before poison/invalidation +@noinline function _check_wrapper_mutation!(tp::TypedPool{T}, new_n, old_n) + for i in (new_n+1):old_n + vec = tp.vectors[i] + for wrapper in released_wrappers_at_slot_i + # Check 1: Memory identity diverged + if wrapper.ref.mem !== vec.ref.mem + @warn "..." maxlog=1 + return + end + # Check 2: wrapper length exceeds backing + if prod(wrapper.size) > length(vec) + @warn "..." maxlog=1 + return + end + end + end +end +``` + +This is a **warning** with `maxlog=1` (one-time advisory per session). +Only runs when safety level S >= 1. Early return after first detection. + +--- + +## Options Under Consideration + +### Option 1: Keep as warning (current) +- **Pro**: Non-disruptive. Pool self-heals. No false-positive breakage. +- **Pro**: `maxlog=1` means minimal noise. +- **Con**: User might not notice the warning. + +### Option 2: Elevate to error (throw) +- **Pro**: Forces user to fix the anti-pattern immediately. +- **Con**: Throwing during `rewind!` would skip cleanup of remaining TypedPools. + - Could partially fix with try/catch in rewind loop, but adds complexity. +- **Con**: The mutation is actually harmless — pool self-heals. +- **Con**: May break valid code where user intentionally resizes (rare but possible). + +### Option 3: Remove runtime check entirely +- **Pro**: Simplest. No code, no maintenance. +- **Pro**: The mutation is demonstrably harmless. +- **Con**: User gets no feedback about potential performance anti-pattern. +- **Con**: GPU VRAM temporary leak goes unnoticed. + +### Option 4: Make it configurable (warn at S=1, error at S=2) +- **Pro**: Flexible — users can choose their strictness level. +- **Con**: AdaptiveArrayPool has binary S (0 or 1), no S=2 level. +- **Con**: Adding S=2 increases type parameter complexity. + +--- + +## Review Questions + +1. **Is there any edge case we missed** where `resize!/push!` on a pool-backed wrapper + could cause data corruption, undefined behavior, or a permanent resource leak? + +2. **Given the self-healing mechanism**, is the runtime check (warning or error) + justified, or is it over-engineering? + +3. **If we keep the check**, should it be: + - A warning (current) — informational, non-blocking + - An error — strict, but risks disrupting rewind cleanup + - Something else (e.g., debug log, opt-in only) + +4. **The compile-time check (PoolMutationError) is a hard error.** Given that + mutation is actually harmless, should this also be downgraded to a warning? + Or is compile-time strictness still valuable as a "lint" for anti-patterns? + +--- + +## Appendix: Key Source Files + +| File | Description | +|------|-------------| +| `src/acquire.jl:234-261` | CPU `get_array!` — wrapper creation/reuse with `setfield!(:ref)` | +| `src/state.jl:258-314` | `_invalidate_released_slots!` — poison + resize + dims zeroing | +| `src/debug.jl:352-456` | `_check_wrapper_mutation!` — CPU runtime mutation detection | +| `src/macros.jl:2448-2660` | `PoolMutationError` + compile-time `_check_structural_mutation` | +| `ext/.../acquire.jl` | GPU `get_array!` — wrapper creation with DataRef identity check | +| `ext/.../debug.jl` | GPU `_check_wrapper_mutation!` — DataRef-based detection | diff --git a/ext/AdaptiveArrayPoolsCUDAExt/debug.jl b/ext/AdaptiveArrayPoolsCUDAExt/debug.jl index 2d33928..3e74112 100644 --- a/ext/AdaptiveArrayPoolsCUDAExt/debug.jl +++ b/ext/AdaptiveArrayPoolsCUDAExt/debug.jl @@ -15,7 +15,7 @@ using AdaptiveArrayPools: _runtime_check, _validate_pool_return, _set_pending_callsite!, _maybe_record_borrow!, - _invalidate_released_slots!, _zero_dims_tuple, + _invalidate_released_slots!, _check_wrapper_mutation!, _zero_dims_tuple, _throw_pool_escape_error, PoolRuntimeEscapeError @@ -40,19 +40,76 @@ Fill a CuVector with a detectable sentinel value (NaN for floats, typemax for in return nothing end +# ============================================================================== +# Runtime Structural Mutation Detection for CuTypedPool (S >= 1) +# ============================================================================== +# +# Detects if pool-backed CuArray wrappers were structurally mutated (resize!, etc.) +# by comparing the wrapper's DataRef and dims against the backing vector at rewind time. +# Called from _invalidate_released_slots! BEFORE poison/invalidation zeroes everything. + +""" + _check_wrapper_mutation!(tp::CuTypedPool{T}, new_n, old_n) + +Check released slots for structural mutation of cached CuArray wrappers. +Two checks per wrapper: +1. DataRef identity (`wrapper.data.rc !== vec.data.rc`) — detects GPU buffer reallocation +2. Length divergence (`prod(wrapper.dims) > length(vec)`) — detects any size growth +""" +@noinline function AdaptiveArrayPools._check_wrapper_mutation!(tp::CuTypedPool{T}, new_n::Int, old_n::Int) where {T} + for i in (new_n + 1):old_n + @inbounds vec = tp.vectors[i] + vec_len = length(vec) + + for N_idx in 1:length(tp.arr_wrappers) + wrappers_for_N = @inbounds tp.arr_wrappers[N_idx] + wrappers_for_N === nothing && continue + wrappers = wrappers_for_N::Vector{Any} + i > length(wrappers) && continue + wrapper = @inbounds wrappers[i] + wrapper === nothing && continue + + cu = wrapper::CuArray + # Check 1: DataRef identity — detects GPU buffer reallocation from resize! beyond capacity + if cu.data.rc !== vec.data.rc + @warn "Pool-backed CuArray{$T}: resize!/push! caused GPU buffer reallocation " * + "(slot $i). Pooling benefits (zero-alloc reuse) may be lost; " * + "temporary extra GPU memory retention may occur. " * + "Consider requesting the exact size via acquire!(pool, T, n) if known in advance." maxlog = 1 + return + end + # Check 2: wrapper length exceeds backing vector — detects growth beyond backing + wrapper_len = prod(cu.dims) + if wrapper_len > vec_len + @warn "Pool-backed CuArray{$T}: wrapper grew beyond backing vector " * + "(slot $i, wrapper: $wrapper_len, backing: $vec_len). " * + "Pooling benefits (zero-alloc reuse) may be lost; " * + "temporary extra GPU memory retention may occur. " * + "Consider requesting the exact size via acquire!(pool, T, n) if known in advance." maxlog = 1 + return + end + end + end + return nothing +end + # ============================================================================== # _invalidate_released_slots! for CuTypedPool (S=1) # ============================================================================== # # Overrides the no-op fallback in base. On CUDA: # - S=0: no-op (base _rewind_typed_pool! gates with S >= 1, so never called) -# - S=1: poison released CuVectors + invalidate arr_wrappers +# - S=1: mutation check + poison released CuVectors + invalidate arr_wrappers # - NO resize!(cuv, 0) — would free GPU memory; use _resize_to_fit! instead @noinline function AdaptiveArrayPools._invalidate_released_slots!( tp::CuTypedPool{T}, old_n_active::Int, S::Int ) where {T} new_n = tp.n_active + # S=1: check for structural mutation before invalidation (wrappers still intact) + if S >= 1 + _check_wrapper_mutation!(tp, new_n, old_n_active) + end # Poison released CuVectors + shrink logical length to 0 for i in (new_n + 1):old_n_active _cuda_poison_fill!(@inbounds tp.vectors[i]) diff --git a/ext/AdaptiveArrayPoolsMetalExt/debug.jl b/ext/AdaptiveArrayPoolsMetalExt/debug.jl index 934e273..7a1316a 100644 --- a/ext/AdaptiveArrayPoolsMetalExt/debug.jl +++ b/ext/AdaptiveArrayPoolsMetalExt/debug.jl @@ -15,7 +15,7 @@ using AdaptiveArrayPools: _runtime_check, _validate_pool_return, _set_pending_callsite!, _maybe_record_borrow!, - _invalidate_released_slots!, _zero_dims_tuple, + _invalidate_released_slots!, _check_wrapper_mutation!, _zero_dims_tuple, _throw_pool_escape_error, PoolRuntimeEscapeError @@ -40,19 +40,76 @@ Fill a MtlArray with a detectable sentinel value (NaN for floats, typemax for in return nothing end +# ============================================================================== +# Runtime Structural Mutation Detection for MetalTypedPool (R >= 1) +# ============================================================================== +# +# Detects if pool-backed MtlArray wrappers were structurally mutated (resize!, etc.) +# by comparing the wrapper's DataRef and dims against the backing vector at rewind time. +# Called from _invalidate_released_slots! BEFORE poison/invalidation zeroes everything. + +""" + _check_wrapper_mutation!(tp::MetalTypedPool{T,S}, new_n, old_n) + +Check released slots for structural mutation of cached MtlArray wrappers. +Two checks per wrapper: +1. DataRef identity (`wrapper.data.rc !== vec.data.rc`) — detects GPU buffer reallocation +2. Length divergence (`prod(wrapper.dims) > length(vec)`) — detects any size growth +""" +@noinline function AdaptiveArrayPools._check_wrapper_mutation!(tp::MetalTypedPool{T, S}, new_n::Int, old_n::Int) where {T, S} + for i in (new_n + 1):old_n + @inbounds vec = tp.vectors[i] + vec_len = length(vec) + + for N_idx in 1:length(tp.arr_wrappers) + wrappers_for_N = @inbounds tp.arr_wrappers[N_idx] + wrappers_for_N === nothing && continue + wrappers = wrappers_for_N::Vector{Any} + i > length(wrappers) && continue + wrapper = @inbounds wrappers[i] + wrapper === nothing && continue + + mtl = wrapper::MtlArray + # Check 1: DataRef identity — detects GPU buffer reallocation from resize! beyond capacity + if getfield(mtl, :data).rc !== getfield(vec, :data).rc + @warn "Pool-backed MtlArray{$T}: resize!/push! caused GPU buffer reallocation " * + "(slot $i). Pooling benefits (zero-alloc reuse) may be lost; " * + "temporary extra GPU memory retention may occur. " * + "Consider requesting the exact size via acquire!(pool, T, n) if known in advance." maxlog = 1 + return + end + # Check 2: wrapper length exceeds backing vector — detects growth beyond backing + wrapper_len = prod(getfield(mtl, :dims)) + if wrapper_len > vec_len + @warn "Pool-backed MtlArray{$T}: wrapper grew beyond backing vector " * + "(slot $i, wrapper: $wrapper_len, backing: $vec_len). " * + "Pooling benefits (zero-alloc reuse) may be lost; " * + "temporary extra GPU memory retention may occur. " * + "Consider requesting the exact size via acquire!(pool, T, n) if known in advance." maxlog = 1 + return + end + end + end + return nothing +end + # ============================================================================== # _invalidate_released_slots! for MetalTypedPool (R=1) # ============================================================================== # # Overrides the no-op fallback in base. On Metal: # - R=0: no-op (base _rewind_typed_pool! gates with S >= 1, so never called) -# - R=1: poison released MtlArrays + invalidate arr_wrappers +# - R=1: mutation check + poison released MtlArrays + invalidate arr_wrappers # - NO resize!(mtl, 0) — would free GPU memory; use _resize_to_fit! instead @noinline function AdaptiveArrayPools._invalidate_released_slots!( tp::MetalTypedPool{T, S}, old_n_active::Int, safety::Int ) where {T, S} new_n = tp.n_active + # R=1: check for structural mutation before invalidation (wrappers still intact) + if safety >= 1 + _check_wrapper_mutation!(tp, new_n, old_n_active) + end # Poison released MtlArrays + shrink logical length to 0 for i in (new_n + 1):old_n_active _metal_poison_fill!(@inbounds tp.vectors[i]) diff --git a/src/AdaptiveArrayPools.jl b/src/AdaptiveArrayPools.jl index 32ebcd5..d409cea 100644 --- a/src/AdaptiveArrayPools.jl +++ b/src/AdaptiveArrayPools.jl @@ -9,7 +9,7 @@ export zeros!, ones!, trues!, falses!, similar!, reshape!, default_eltype # Con export Bit # Sentinel type for BitArray (use with acquire!, trues!, falses!) export @with_pool, @maybe_with_pool, @safe_with_pool, @safe_maybe_with_pool export STATIC_POOLING, MAYBE_POOLING, RUNTIME_CHECK -export PoolEscapeError, EscapePoint +export PoolEscapeError, EscapePoint, PoolMutationError, MutationPoint export checkpoint!, rewind!, reset! 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) diff --git a/src/debug.jl b/src/debug.jl index 611a924..33d1ce6 100644 --- a/src/debug.jl +++ b/src/debug.jl @@ -348,3 +348,113 @@ no borrow was recorded (S=0 or non-macro path without callsite info). log === nothing && return nothing return get(log, v, nothing) end + +# ============================================================================== +# Runtime Structural Mutation Detection (S >= 1) +# ============================================================================== +# +# Detects if pool-backed Array wrappers were structurally mutated (resize!, push!, etc.) +# by comparing the wrapper's MemoryRef against the backing vector's MemoryRef at rewind time. +# +# Called from _invalidate_released_slots! BEFORE poison/invalidation zeroes everything. +# Uses @warn (not throw) because throwing during rewind would skip cleanup of other pools. + +# No-op fallback for extension types (e.g. CuTypedPool) and legacy (1.10) TypedPool/BitTypedPool +# (legacy structs lack arr_wrappers field — they use N-way nd_arrays cache instead) +_check_wrapper_mutation!(::AbstractTypedPool, ::Int, ::Int) = nothing + +# Julia 1.11+: TypedPool uses arr_wrappers (1:1 wrappers) and MemoryRef-based Array internals. +# Must not be defined on 1.10 where TypedPool has no arr_wrappers and Array has no :ref field. +@static if VERSION >= v"1.11-" + + """ + _check_wrapper_mutation!(tp::TypedPool{T}, new_n, old_n) + + Check released slots for structural mutation of cached Array wrappers. + Compares wrapper's Memory identity and length against the backing vector. + + Called before invalidation (resize! to 0, setfield! size to zeros) while both + wrapper and backing vector are still intact. + """ + @noinline function _check_wrapper_mutation!(tp::TypedPool{T}, new_n::Int, old_n::Int) where {T} + for i in (new_n + 1):old_n + @inbounds vec = tp.vectors[i] + vec_mem = getfield(vec, :ref).mem + vec_len = length(vec) + + for N_idx in 1:length(tp.arr_wrappers) + wrappers_for_N = @inbounds tp.arr_wrappers[N_idx] + wrappers_for_N === nothing && continue + wrappers = wrappers_for_N::Vector{Any} + i > length(wrappers) && continue + wrapper = @inbounds wrappers[i] + wrapper === nothing && continue + + arr = wrapper::Array + # Check 1: Memory identity — detects reallocation from resize!/push! beyond capacity + if getfield(arr, :ref).mem !== vec_mem + @warn "Pool-backed Array{$T}: resize!/push! caused memory reallocation " * + "(slot $i). Pooling benefits (zero-alloc reuse) may be lost; " * + "temporary extra memory retention may occur. " * + "Consider requesting the exact size via acquire!(pool, T, n) if known in advance." maxlog = 1 + return + end + # Check 2: wrapper length exceeds backing vector — detects growth beyond backing + wrapper_len = prod(getfield(arr, :size)) + if wrapper_len > vec_len + @warn "Pool-backed Array{$T}: wrapper grew beyond backing vector " * + "(slot $i, wrapper: $wrapper_len, backing: $vec_len). " * + "Pooling benefits (zero-alloc reuse) may be lost; " * + "temporary extra memory retention may occur. " * + "Consider requesting the exact size via acquire!(pool, T, n) if known in advance." maxlog = 1 + return + end + end + end + return nothing + end + + """ + _check_wrapper_mutation!(tp::BitTypedPool, new_n, old_n) + + Check released BitArray wrappers for structural mutation. + BitArrays share their `chunks` Vector{UInt64} with the backing BitVector. + """ + @noinline function _check_wrapper_mutation!(tp::BitTypedPool, new_n::Int, old_n::Int) + for i in (new_n + 1):old_n + @inbounds bv = tp.vectors[i] + bv_chunks = bv.chunks + bv_len = length(bv) + + for N_idx in 1:length(tp.arr_wrappers) + wrappers_for_N = @inbounds tp.arr_wrappers[N_idx] + wrappers_for_N === nothing && continue + wrappers = wrappers_for_N::Vector{Any} + i > length(wrappers) && continue + wrapper = @inbounds wrappers[i] + wrapper === nothing && continue + + ba = wrapper::BitArray + # Check 1: chunks identity — detects reallocation + if ba.chunks !== bv_chunks + @warn "Pool-backed BitArray: resize!/push! caused chunks reallocation " * + "(slot $i). Pooling benefits (zero-alloc reuse) may be lost; " * + "temporary extra memory retention may occur. " * + "Consider requesting the exact size via acquire!(pool, Bit, n) if known in advance." maxlog = 1 + return + end + # Check 2: wrapper length exceeds backing + if ba.len > bv_len + @warn "Pool-backed BitArray: wrapper grew beyond backing BitVector " * + "(slot $i, wrapper: $(ba.len) bits, backing: $bv_len). " * + "Pooling benefits (zero-alloc reuse) may be lost; " * + "temporary extra memory retention may occur. " * + "Consider requesting the exact size via acquire!(pool, Bit, n) if known in advance." maxlog = 1 + return + end + end + end + return nothing + end + +end # @static if VERSION >= v"1.11-" diff --git a/src/macros.jl b/src/macros.jl index 11d717a..e04b451 100644 --- a/src/macros.jl +++ b/src/macros.jl @@ -670,6 +670,9 @@ function _generate_pool_code(pool_name, expr, force_enable; safe::Bool = false, _esc = _check_compile_time_escape(expr, pool_name, source) _esc !== nothing && return :(throw($_esc)) + # Compile-time structural mutation detection (zero runtime cost) + _check_structural_mutation(expr, pool_name, source) + # Block logic — shared with backend-specific code generation inner = _generate_block_inner(pool_name, expr, safe, source) @@ -889,6 +892,9 @@ function _generate_pool_code_with_backend(backend::Symbol, pool_name, expr, forc _esc = _check_compile_time_escape(expr, pool_name, source) _esc !== nothing && return :(throw($_esc)) + # Compile-time structural mutation detection (zero runtime cost) + _check_structural_mutation(expr, pool_name, source) + # Block logic with runtime check inner = _generate_block_inner(pool_name, expr, safe, source) pool_getter = :($_get_pool_for_backend($(Val{backend}()))) @@ -913,6 +919,9 @@ function _generate_pool_code_with_backend(backend::Symbol, pool_name, expr, forc _esc = _check_compile_time_escape(expr, pool_name, source) _esc !== nothing && return :(throw($_esc)) + # Compile-time structural mutation detection (zero runtime cost) + _check_structural_mutation(expr, pool_name, source) + # Block logic (force_enable=true path) inner = _generate_block_inner(pool_name, expr, safe, source) pool_getter = :($_get_pool_for_backend($(Val{backend}()))) @@ -949,6 +958,9 @@ function _generate_function_pool_code_with_backend(backend::Symbol, pool_name, f _esc = _check_compile_time_escape(body, pool_name, source) _esc !== nothing && return :(throw($_esc)) + # Compile-time structural mutation detection (zero runtime cost) + _check_structural_mutation(body, pool_name, source) + # Function body inner — no break/continue transform (can't break out of a function) inner = _generate_function_inner(pool_name, body, safe, source) @@ -999,6 +1011,9 @@ function _generate_function_pool_code(pool_name, func_def, force_enable, disable _esc = _check_compile_time_escape(body, pool_name, source) _esc !== nothing && return :(throw($_esc)) + # Compile-time structural mutation detection (zero runtime cost) + _check_structural_mutation(body, pool_name, source) + # Function body inner — no break/continue transform (can't break out of a function) inner = _generate_function_inner(pool_name, body, safe, source) @@ -2429,3 +2444,228 @@ function _check_compile_time_escape(expr, pool_name, source::Union{LineNumberNod line = source !== nothing ? source.line : nothing throw(PoolEscapeError(sorted, file, line, points, var_info, declarations)) end + +# ============================================================================== +# PoolMutationError — Compile-time structural mutation detection +# ============================================================================== + +"""Per-mutation-site detail: which expression, at which line, mutates which var.""" +struct MutationPoint + expr::Any + line::Union{Int, Nothing} + var::Symbol + func::Symbol +end + +""" + PoolMutationError <: Exception + +Thrown at macro expansion time when structural mutation functions (`resize!`, +`push!`, `pop!`, etc.) are called on pool-backed variables within `@with_pool` / +`@maybe_with_pool` blocks. + +Pool-backed arrays share memory with the pool's backing storage. Structural +mutations may cause pooling benefits (zero-alloc reuse) to be lost and +temporary extra memory retention until the next `acquire!` at the same slot. + +This is a compile-time check with zero runtime cost. +""" +struct PoolMutationError <: Exception + file::Union{String, Nothing} + line::Union{Int, Nothing} + points::Vector{MutationPoint} + declarations::Vector{DeclarationSite} +end + +function Base.showerror(io::IO, e::PoolMutationError) + # Header + printstyled(io, "PoolMutationError"; color = :red, bold = true) + printstyled(io, " (compile-time)"; color = :light_black) + println(io) + + # Descriptive message + println(io) + n = length(e.points) + if n == 1 + printstyled(io, " Structural mutation of pool-backed array detected:\n"; color = :light_black) + else + printstyled(io, " ", n, " structural mutations of pool-backed arrays detected:\n"; color = :light_black) + end + + # Declaration sites + if !isempty(e.declarations) + println(io) + printstyled(io, " Declarations:\n"; bold = true) + for (idx, decl) in enumerate(e.declarations) + printstyled(io, " [", idx, "] "; color = :light_black) + printstyled(io, string(decl.expr); color = :cyan) + decl_file = (decl.file !== nothing && decl.file !== :none) ? decl.file : e.file + loc = _format_location_str(decl_file, decl.line) + if loc !== nothing + printstyled(io, " ["; color = :cyan, bold = true) + printstyled(io, loc; color = :cyan, bold = true) + printstyled(io, "] "; color = :cyan, bold = true) + end + println(io) + end + end + + # Mutation points + println(io) + label = n == 1 ? " Dangerous call:" : " Dangerous calls:" + printstyled(io, label, "\n"; bold = true) + for (idx, pt) in enumerate(e.points) + printstyled(io, " [", idx, "] "; color = :light_black) + printstyled(io, string(pt.func); color = :red, bold = true) + printstyled(io, "("; color = :normal) + printstyled(io, string(pt.var); color = :red, bold = true) + printstyled(io, ", ...)"; color = :normal) + loc = _format_point_location(e.file, pt.line) + if loc !== nothing + printstyled(io, " ["; color = :magenta, bold = true) + printstyled(io, loc; color = :magenta, bold = true) + printstyled(io, "] "; color = :magenta, bold = true) + end + println(io) + end + + # Suggestion + println(io) + printstyled(io, " Tip: "; bold = true) + printstyled(io, "Consider requesting the exact size via "; color = :light_black) + printstyled(io, "acquire!(pool, T, n)"; bold = true) + printstyled(io, " if known in advance.\n"; color = :light_black) + printstyled(io, " resize!/push!/pop! may trigger memory reallocation — pooling benefits\n"; color = :light_black) + printstyled(io, " (zero-alloc reuse) may be lost; temporary extra memory retention may occur.\n"; color = :light_black) + + # False positive + println(io) + printstyled(io, " False positive?\n"; bold = true) + printstyled(io, " Please file an issue at "; color = :light_black) + printstyled(io, "https://github.com/ProjectTorreyPines/AdaptiveArrayPools.jl/issues"; bold = true) + return printstyled(io, "\n with a minimal reproducer so we can improve the mutation detector.\n"; color = :light_black) +end + +# Suppress stacktrace +Base.showerror(io::IO, e::PoolMutationError, ::Any; backtrace = true) = showerror(io, e) + +# ============================================================================== +# Internal: Structural Mutation Detection +# ============================================================================== + +"""Set of function names that structurally mutate arrays (resize, grow, shrink).""" +const _STRUCTURAL_MUTATION_NAMES = Set{Symbol}( + [ + :resize!, :push!, :pop!, :pushfirst!, :popfirst!, + :append!, :prepend!, :deleteat!, :insert!, :splice!, + :empty!, :sizehint!, + ] +) + +""" + _is_mutation_call(expr, acquired) -> Union{Tuple{Symbol, Symbol}, Nothing} + +Check if `expr` is a call to a structural mutation function with a pool-backed +variable as the first argument. Returns `(func_name, var_name)` or `nothing`. +""" +function _is_mutation_call(expr, acquired::Set{Symbol}) + expr isa Expr && expr.head == :call && length(expr.args) >= 2 || return nothing + + fn = expr.args[1] + first_arg = expr.args[2] + first_arg isa Symbol && first_arg in acquired || return nothing + + # Direct name: resize!(v, ...) + if fn isa Symbol && fn in _STRUCTURAL_MUTATION_NAMES + return (fn, first_arg) + end + # Qualified name: Base.resize!(v, ...) + if fn isa Expr && fn.head == :. && length(fn.args) >= 2 + qn = fn.args[end] + if qn isa QuoteNode && qn.value isa Symbol && qn.value in _STRUCTURAL_MUTATION_NAMES + return (qn.value, first_arg) + end + end + return nothing +end + +""" + _find_mutation_calls(expr, acquired) -> Vector{MutationPoint} + +Walk AST to find all structural mutation calls on pool-backed variables. +Tracks LineNumberNodes for precise error locations. Skips nested function +definitions (lambdas within @with_pool are separate scopes). +""" +function _find_mutation_calls(expr, acquired::Set{Symbol}) + points = MutationPoint[] + _find_mutation_calls!(points, expr, acquired, nothing) + return points +end + +function _find_mutation_calls!(points, expr, acquired, current_line::Union{Int, Nothing}) + expr isa Expr || return + + # Skip nested function definitions (they have their own scope) + expr.head in (:function, :(->)) && return + + # Track line numbers + if expr.head == :block + line = current_line + for arg in expr.args + if arg isa LineNumberNode + line = arg.line + else + _find_mutation_calls!(points, arg, acquired, line) + end + end + return + end + + # Check if this is a mutation call + result = _is_mutation_call(expr, acquired) + if result !== nothing + func, var = result + push!(points, MutationPoint(expr, current_line, var, func)) + end + + # Recurse into sub-expressions + for arg in expr.args + if arg isa LineNumberNode + current_line = arg.line + else + _find_mutation_calls!(points, arg, acquired, current_line) + end + end + return +end + +""" + _check_structural_mutation(expr, pool_name, source) + +Compile-time (macro expansion time) structural mutation detection. + +Checks if any pool-backed variable is passed to `resize!`, `push!`, `pop!`, +or other functions that change array structure. These operations may cause +pooling benefits (zero-alloc reuse) to be lost and temporary extra memory +retention, because pool-backed arrays share memory with backing storage. + +Skipped when `STATIC_POOLING = false` (pooling disabled, acquire returns normal arrays). +""" +function _check_structural_mutation(expr, pool_name, source::Union{LineNumberNode, Nothing}) + acquired = _extract_acquired_vars(expr, pool_name) + isempty(acquired) && return nothing + + _remove_flat_reassigned!(expr, acquired, pool_name) + isempty(acquired) && return nothing + + points = _find_mutation_calls(expr, acquired) + isempty(points) && return nothing + + # Collect declaration sites for the mutated variables + mutated_vars = Set{Symbol}(pt.var for pt in points) + declarations = _extract_declaration_sites(expr, mutated_vars) + + file = source !== nothing ? string(source.file) : nothing + line = source !== nothing ? source.line : nothing + throw(PoolMutationError(file, line, points, declarations)) +end diff --git a/src/state.jl b/src/state.jl index e1d82b4..0a491cb 100644 --- a/src/state.jl +++ b/src/state.jl @@ -257,6 +257,10 @@ end @noinline function _invalidate_released_slots!(tp::TypedPool{T}, old_n_active::Int, S::Int) where {T} new_n = tp.n_active + # S=1: check for structural mutation before invalidation (wrappers still intact) + if S >= 1 + _check_wrapper_mutation!(tp, new_n, old_n_active) + end # S=1: poison vectors with NaN/sentinel before structural invalidation if S >= 1 _poison_released_vectors!(tp, old_n_active) @@ -281,6 +285,10 @@ end @noinline function _invalidate_released_slots!(tp::BitTypedPool, old_n_active::Int, S::Int) new_n = tp.n_active + # S=1: check for structural mutation before invalidation (wrappers still intact) + if S >= 1 + _check_wrapper_mutation!(tp, new_n, old_n_active) + end # S=1: poison BitVectors (all bits set to true) if S >= 1 _poison_released_vectors!(tp, old_n_active) diff --git a/test/cuda/runtests.jl b/test/cuda/runtests.jl index f4e3ce5..cdff379 100644 --- a/test/cuda/runtests.jl +++ b/test/cuda/runtests.jl @@ -46,5 +46,6 @@ else include("test_convenience.jl") include("test_disabled_pool.jl") include("test_cuda_safety.jl") + include("test_runtime_mutation.jl") end end diff --git a/test/cuda/test_runtime_mutation.jl b/test/cuda/test_runtime_mutation.jl new file mode 100644 index 0000000..b03b644 --- /dev/null +++ b/test/cuda/test_runtime_mutation.jl @@ -0,0 +1,133 @@ +import AdaptiveArrayPools: _check_wrapper_mutation! + +@testset "Runtime Structural Mutation Detection (CUDA)" begin + + # ============================================================================== + # CuTypedPool: DataRef reallocation detection + # ============================================================================== + + @testset "resize! reallocation detected (CuTypedPool)" begin + pool = _make_cuda_pool(1) + checkpoint!(pool) + v = acquire!(pool, Float32, 10) + CUDA.fill!(v, 1.0f0) + + # Resize far beyond capacity → forces new GPU buffer allocation (new DataRef) + resize!(v, 100_000) + + # rewind! should emit a warning about structural mutation + @test_logs (:warn, r"resize!.*reallocation") rewind!(pool) + end + + @testset "no mutation → no warning (CuTypedPool)" begin + pool = _make_cuda_pool(1) + checkpoint!(pool) + v = acquire!(pool, Float32, 100) + CUDA.fill!(v, 42.0f0) # element mutation is fine + + # rewind! should NOT emit any mutation warning + @test_logs rewind!(pool) + end + + @testset "S=0 → no check (CuTypedPool)" begin + pool = _make_cuda_pool(0) + checkpoint!(pool) + v = acquire!(pool, Float32, 10) + resize!(v, 100_000) + + # S=0: no invalidation, no mutation check, no warning + @test_logs rewind!(pool) + end + + # ============================================================================== + # CuTypedPool: wrapper length exceeds backing vector + # ============================================================================== + + @testset "wrapper length > backing detected (CuTypedPool)" begin + pool = _make_cuda_pool(1) + checkpoint!(pool) + v = acquire!(pool, Float32, 10) + + # Get the TypedPool internals + tp = pool.float32 + vec = tp.vectors[tp.n_active] + vec_len = length(vec) + + # Look up cached 1D wrapper and artificially inflate its dims + wrappers_1d = 1 <= length(tp.arr_wrappers) ? tp.arr_wrappers[1] : nothing + if wrappers_1d !== nothing && tp.n_active <= length(wrappers_1d) + wrapper = wrappers_1d[tp.n_active] + if wrapper !== nothing + cu = wrapper::CuArray + # Artificially set wrapper dims larger than backing + setfield!(cu, :dims, (vec_len + 100,)) + @test_logs (:warn, r"grew beyond backing") rewind!(pool) + else + rewind!(pool) # no wrapper cached yet, skip + end + else + rewind!(pool) + end + end + + # ============================================================================== + # N-D wrapper mutation detection + # ============================================================================== + + @testset "N-D wrapper mutation detected (CuTypedPool)" begin + pool = _make_cuda_pool(1) + checkpoint!(pool) + mat = acquire!(pool, Float32, 10, 10) # 100 elements + CUDA.fill!(mat, 1.0f0) + + # Get the 2D wrapper and manually break its dims + tp = pool.float32 + wrappers_2d = length(tp.arr_wrappers) >= 2 ? tp.arr_wrappers[2] : nothing + if wrappers_2d !== nothing && tp.n_active <= length(wrappers_2d) + wrapper = wrappers_2d[tp.n_active] + if wrapper !== nothing + cu = wrapper::CuArray + # Artificially set wrapper dims to something huge + setfield!(cu, :dims, (1000, 1000)) + @test_logs (:warn, r"grew beyond backing") rewind!(pool) + else + rewind!(pool) + end + else + rewind!(pool) + end + end + + # ============================================================================== + # Multiple slots: only first mutation triggers warning + # ============================================================================== + + @testset "multiple slots: one warning per rewind" begin + pool = _make_cuda_pool(1) + checkpoint!(pool) + v1 = acquire!(pool, Float32, 10) + v2 = acquire!(pool, Float32, 10) + resize!(v1, 100_000) + resize!(v2, 100_000) + + # Should only warn once (first detected mutation), not twice + @test_logs (:warn, r"resize!.*reallocation") rewind!(pool) + end + + # ============================================================================== + # Fallback type (pool.others) mutation detection + # ============================================================================== + + @testset "resize! reallocation detected (others type)" begin + pool = _make_cuda_pool(1) + checkpoint!(pool) + v = acquire!(pool, UInt8, 16) + CUDA.fill!(v, UInt8(1)) + + # Resize far beyond capacity + resize!(v, 100_000) + + @test_logs (:warn, r"resize!.*reallocation") rewind!(pool) + end + +end diff --git a/test/metal/runtests.jl b/test/metal/runtests.jl index 5d9932f..f8d2ad0 100644 --- a/test/metal/runtests.jl +++ b/test/metal/runtests.jl @@ -45,6 +45,7 @@ else include("test_convenience.jl") include("test_disabled_pool.jl") include("test_metal_safety.jl") + include("test_runtime_mutation.jl") include("test_reshape.jl") include("test_task_local_pool.jl") end diff --git a/test/metal/test_runtime_mutation.jl b/test/metal/test_runtime_mutation.jl new file mode 100644 index 0000000..293c76c --- /dev/null +++ b/test/metal/test_runtime_mutation.jl @@ -0,0 +1,133 @@ +import AdaptiveArrayPools: _check_wrapper_mutation! + +@testset "Runtime Structural Mutation Detection (Metal)" begin + + # ============================================================================== + # MetalTypedPool: DataRef reallocation detection + # ============================================================================== + + @testset "resize! reallocation detected (MetalTypedPool)" begin + pool = _make_metal_pool(1) + checkpoint!(pool) + v = acquire!(pool, Float32, 10) + Metal.fill!(v, 1.0f0) + + # Resize far beyond capacity → forces new GPU buffer allocation (new DataRef) + resize!(v, 100_000) + + # rewind! should emit a warning about structural mutation + @test_logs (:warn, r"resize!.*reallocation") rewind!(pool) + end + + @testset "no mutation → no warning (MetalTypedPool)" begin + pool = _make_metal_pool(1) + checkpoint!(pool) + v = acquire!(pool, Float32, 100) + Metal.fill!(v, 42.0f0) # element mutation is fine + + # rewind! should NOT emit any mutation warning + @test_logs rewind!(pool) + end + + @testset "R=0 → no check (MetalTypedPool)" begin + pool = _make_metal_pool(0) + checkpoint!(pool) + v = acquire!(pool, Float32, 10) + resize!(v, 100_000) + + # R=0: no invalidation, no mutation check, no warning + @test_logs rewind!(pool) + end + + # ============================================================================== + # MetalTypedPool: wrapper length exceeds backing vector + # ============================================================================== + + @testset "wrapper length > backing detected (MetalTypedPool)" begin + pool = _make_metal_pool(1) + checkpoint!(pool) + v = acquire!(pool, Float32, 10) + + # Get the TypedPool internals + tp = pool.float32 + vec = tp.vectors[tp.n_active] + vec_len = length(vec) + + # Look up cached 1D wrapper and artificially inflate its dims + wrappers_1d = 1 <= length(tp.arr_wrappers) ? tp.arr_wrappers[1] : nothing + if wrappers_1d !== nothing && tp.n_active <= length(wrappers_1d) + wrapper = wrappers_1d[tp.n_active] + if wrapper !== nothing + mtl = wrapper::MtlArray + # Artificially set wrapper dims larger than backing + setfield!(mtl, :dims, (vec_len + 100,)) + @test_logs (:warn, r"grew beyond backing") rewind!(pool) + else + rewind!(pool) # no wrapper cached yet, skip + end + else + rewind!(pool) + end + end + + # ============================================================================== + # N-D wrapper mutation detection + # ============================================================================== + + @testset "N-D wrapper mutation detected (MetalTypedPool)" begin + pool = _make_metal_pool(1) + checkpoint!(pool) + mat = acquire!(pool, Float32, 10, 10) # 100 elements + Metal.fill!(mat, 1.0f0) + + # Get the 2D wrapper and manually break its dims + tp = pool.float32 + wrappers_2d = length(tp.arr_wrappers) >= 2 ? tp.arr_wrappers[2] : nothing + if wrappers_2d !== nothing && tp.n_active <= length(wrappers_2d) + wrapper = wrappers_2d[tp.n_active] + if wrapper !== nothing + mtl = wrapper::MtlArray + # Artificially set wrapper dims to something huge + setfield!(mtl, :dims, (1000, 1000)) + @test_logs (:warn, r"grew beyond backing") rewind!(pool) + else + rewind!(pool) + end + else + rewind!(pool) + end + end + + # ============================================================================== + # Multiple slots: only first mutation triggers warning + # ============================================================================== + + @testset "multiple slots: one warning per rewind" begin + pool = _make_metal_pool(1) + checkpoint!(pool) + v1 = acquire!(pool, Float32, 10) + v2 = acquire!(pool, Float32, 10) + resize!(v1, 100_000) + resize!(v2, 100_000) + + # Should only warn once (first detected mutation), not twice + @test_logs (:warn, r"resize!.*reallocation") rewind!(pool) + end + + # ============================================================================== + # Fallback type (pool.others) mutation detection + # ============================================================================== + + @testset "resize! reallocation detected (others type)" begin + pool = _make_metal_pool(1) + checkpoint!(pool) + v = acquire!(pool, UInt8, 16) + Metal.fill!(v, UInt8(1)) + + # Resize far beyond capacity + resize!(v, 100_000) + + @test_logs (:warn, r"resize!.*reallocation") rewind!(pool) + end + +end diff --git a/test/runtests.jl b/test/runtests.jl index c45b7e4..b2852e1 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -30,6 +30,8 @@ else include("test_borrow_registry.jl") include("test_safety.jl") include("test_compile_escape.jl") + include("test_compile_mutation.jl") + include("test_runtime_mutation.jl") include("test_macro_expansion.jl") include("test_macro_internals.jl") include("test_zero_allocation.jl") @@ -55,6 +57,7 @@ else include("test_debug.jl") include("test_safety.jl") include("test_compile_escape.jl") + include("test_compile_mutation.jl") include("test_macro_expansion.jl") include("test_macro_internals.jl") include("test_zero_allocation.jl") diff --git a/test/test_compile_mutation.jl b/test/test_compile_mutation.jl new file mode 100644 index 0000000..b8cac5a --- /dev/null +++ b/test/test_compile_mutation.jl @@ -0,0 +1,300 @@ +import AdaptiveArrayPools: _extract_acquired_vars, _check_structural_mutation, + _is_mutation_call, _find_mutation_calls, + _STRUCTURAL_MUTATION_NAMES, + MutationPoint + +@testset "Compile-Time Structural Mutation Detection" begin + + # ============================================================================== + # _is_mutation_call: detect dangerous calls on acquired variables + # ============================================================================== + + @testset "_is_mutation_call" begin + acquired = Set{Symbol}([:v, :w]) + + # Direct mutation calls + @test _is_mutation_call(:(resize!(v, 200)), acquired) == (:resize!, :v) + @test _is_mutation_call(:(push!(v, 1.0)), acquired) == (:push!, :v) + @test _is_mutation_call(:(pop!(w)), acquired) == (:pop!, :w) + @test _is_mutation_call(:(append!(v, data)), acquired) == (:append!, :v) + @test _is_mutation_call(:(prepend!(v, data)), acquired) == (:prepend!, :v) + @test _is_mutation_call(:(deleteat!(v, 1)), acquired) == (:deleteat!, :v) + @test _is_mutation_call(:(insert!(v, 1, x)), acquired) == (:insert!, :v) + @test _is_mutation_call(:(splice!(v, 1:2)), acquired) == (:splice!, :v) + @test _is_mutation_call(:(empty!(v)), acquired) == (:empty!, :v) + @test _is_mutation_call(:(sizehint!(v, 1000)), acquired) == (:sizehint!, :v) + @test _is_mutation_call(:(pushfirst!(v, 0.0)), acquired) == (:pushfirst!, :v) + @test _is_mutation_call(:(popfirst!(v)), acquired) == (:popfirst!, :v) + + # Qualified name: Base.resize!(v, ...) + @test _is_mutation_call(:(Base.resize!(v, 200)), acquired) == (:resize!, :v) + @test _is_mutation_call(:(Base.push!(w, 1.0)), acquired) == (:push!, :w) + + # Non-acquired variable → nothing + @test _is_mutation_call(:(resize!(x, 200)), acquired) === nothing + + # Non-mutation call → nothing + @test _is_mutation_call(:(sum(v)), acquired) === nothing + @test _is_mutation_call(:(fill!(v, 0.0)), acquired) === nothing + + # Not a call → nothing + @test _is_mutation_call(:v, acquired) === nothing + @test _is_mutation_call(42, acquired) === nothing + end + + # ============================================================================== + # _find_mutation_calls: walk AST to find all mutation sites + # ============================================================================== + + @testset "_find_mutation_calls" begin + acquired = Set{Symbol}([:v, :w]) + + # Single mutation + points = _find_mutation_calls( + quote + v = acquire!(pool, Float64, 100) + resize!(v, 200) + end, + acquired + ) + @test length(points) == 1 + @test points[1].var == :v + @test points[1].func == :resize! + + # Multiple mutations + points = _find_mutation_calls( + quote + push!(v, 1.0) + pop!(w) + append!(v, [2.0, 3.0]) + end, + acquired + ) + @test length(points) == 3 + + # No mutations + points = _find_mutation_calls( + quote + v .= 1.0 + sum(v) + w[1] = 42.0 + end, + acquired + ) + @test isempty(points) + + # Mutation inside nested function definition → skipped + points = _find_mutation_calls( + quote + f = function (x) + resize!(v, 100) + end + end, + acquired + ) + @test isempty(points) + + # Mutation inside lambda → skipped + points = _find_mutation_calls( + quote + f = x -> resize!(v, 100) + end, + acquired + ) + @test isempty(points) + + # Mutation inside if-else → detected + points = _find_mutation_calls( + quote + if condition + resize!(v, 200) + else + push!(w, 1.0) + end + end, + acquired + ) + @test length(points) == 2 + end + + # ============================================================================== + # _check_structural_mutation: full pipeline (throws PoolMutationError) + # ============================================================================== + + @testset "_check_structural_mutation integration" begin + # resize! on acquired variable → throws + @test_throws PoolMutationError _check_structural_mutation( + quote + v = acquire!(pool, Float64, 100) + resize!(v, 200) + end, + :pool, + nothing + ) + + # push! on acquired variable → throws + @test_throws PoolMutationError _check_structural_mutation( + quote + v = acquire!(pool, Float64, 100) + push!(v, 1.0) + end, + :pool, + nothing + ) + + # Alias tracking: w = v; resize!(w, ...) → throws + @test_throws PoolMutationError _check_structural_mutation( + quote + v = acquire!(pool, Float64, 100) + w = v + resize!(w, 200) + end, + :pool, + nothing + ) + + # No mutation → does not throw (returns nothing) + @test _check_structural_mutation( + quote + v = acquire!(pool, Float64, 100) + v .= 1.0 + sum(v) + end, + :pool, + nothing + ) === nothing + + # No acquire → does not throw + @test _check_structural_mutation( + quote + v = zeros(100) + resize!(v, 200) + end, + :pool, + nothing + ) === nothing + + # Reassigned variable → not tracked (safe) + @test _check_structural_mutation( + quote + v = acquire!(pool, Float64, 100) + v = zeros(100) + resize!(v, 200) + end, + :pool, + nothing + ) === nothing + + # zeros!/ones!/similar!/trues!/falses! → also tracked + @test_throws PoolMutationError _check_structural_mutation( + quote + v = zeros!(pool, 100) + resize!(v, 200) + end, + :pool, + nothing + ) + + @test_throws PoolMutationError _check_structural_mutation( + quote + v = ones!(pool, Int64, 100) + push!(v, 42) + end, + :pool, + nothing + ) + + @test_throws PoolMutationError _check_structural_mutation( + quote + v = trues!(pool, 100) + push!(v, false) + end, + :pool, + nothing + ) + + # Qualified Base.resize! also detected + @test_throws PoolMutationError _check_structural_mutation( + quote + v = acquire!(pool, Float64, 100) + Base.resize!(v, 200) + end, + :pool, + nothing + ) + end + + # ============================================================================== + # PoolMutationError message quality + # ============================================================================== + + @testset "PoolMutationError message" begin + err = try + _check_structural_mutation( + quote + v = acquire!(pool, Float64, 100) + resize!(v, 200) + push!(v, 1.0) + end, + :pool, + nothing + ) + catch e + e + end + + @test err isa PoolMutationError + @test length(err.points) == 2 + @test err.points[1].func == :resize! + @test err.points[1].var == :v + @test err.points[2].func == :push! + @test err.points[2].var == :v + + # showerror produces output + buf = IOBuffer() + showerror(buf, err) + msg = String(take!(buf)) + @test occursin("PoolMutationError", msg) + @test occursin("resize!", msg) + @test occursin("push!", msg) + @test occursin("acquire!(pool, T, n)", msg) + end + + # ============================================================================== + # Macro-level integration (actual @with_pool usage) + # ============================================================================== + + @testset "macro integration" begin + # @with_pool with resize! → PoolMutationError at macro expansion + @test_throws PoolMutationError @macroexpand @with_pool pool begin + v = acquire!(pool, Float64, 100) + resize!(v, 200) + end + + # @with_pool with push! → PoolMutationError + @test_throws PoolMutationError @macroexpand @with_pool pool begin + v = acquire!(pool, Float64, 100) + push!(v, 1.0) + end + + # @with_pool without mutation → succeeds + result = @macroexpand @with_pool pool begin + v = acquire!(pool, Float64, 100) + v .= 1.0 + sum(v) + end + @test result isa Expr + + # @safe_with_pool with mutation → also caught + @test_throws PoolMutationError @macroexpand @safe_with_pool pool begin + v = acquire!(pool, Float64, 100) + resize!(v, 200) + end + + # @maybe_with_pool with mutation → also caught + @test_throws PoolMutationError @macroexpand @maybe_with_pool pool begin + v = acquire!(pool, Float64, 100) + pop!(v) + end + end +end diff --git a/test/test_runtime_mutation.jl b/test/test_runtime_mutation.jl new file mode 100644 index 0000000..470c876 --- /dev/null +++ b/test/test_runtime_mutation.jl @@ -0,0 +1,158 @@ +import AdaptiveArrayPools: _make_pool, _check_wrapper_mutation! + +@testset "Runtime Structural Mutation Detection" begin + + # ============================================================================== + # TypedPool: MemoryRef reallocation detection + # ============================================================================== + + @testset "resize! reallocation detected (TypedPool)" begin + pool = _make_pool(true) + checkpoint!(pool) + v = acquire!(pool, Float64, 10) + v .= 1.0 + + # Resize far beyond capacity → forces reallocation (new Memory) + resize!(v, 10_000) + + # rewind! should emit a warning about structural mutation + @test_logs (:warn, r"resize!.*reallocation") rewind!(pool) + end + + @testset "push! reallocation detected (TypedPool)" begin + pool = _make_pool(true) + checkpoint!(pool) + v = acquire!(pool, Float64, 10) + v .= 1.0 + + # push! enough elements to force reallocation + for _ in 1:10_000 + push!(v, 99.0) + end + + @test_logs (:warn, r"resize!.*reallocation") rewind!(pool) + end + + @testset "no mutation → no warning (TypedPool)" begin + pool = _make_pool(true) + checkpoint!(pool) + v = acquire!(pool, Float64, 100) + v .= 42.0 # element mutation is fine + + # rewind! should NOT emit any mutation warning + @test_logs rewind!(pool) + end + + @testset "S=0 → no check (TypedPool)" begin + pool = _make_pool(false) + checkpoint!(pool) + v = acquire!(pool, Float64, 10) + resize!(v, 10_000) + + # S=0: no invalidation, no mutation check, no warning + @test_logs rewind!(pool) + end + + # ============================================================================== + # TypedPool: wrapper length exceeds backing vector + # ============================================================================== + + @testset "wrapper length > backing detected (TypedPool)" begin + pool = _make_pool(true) + checkpoint!(pool) + v = acquire!(pool, Float64, 10) + + # Manually inflate wrapper size beyond backing vector via setfield! + # (simulates in-place resize within Memory capacity but beyond vec length) + tp = AdaptiveArrayPools.get_typed_pool!(pool, Float64) + vec = tp.vectors[tp.n_active] + vec_len = length(vec) + + # Only test if wrapper and vec share same Memory (otherwise MemoryRef check fires first) + wrappers_1d = tp.arr_wrappers[1] + if wrappers_1d !== nothing && tp.n_active <= length(wrappers_1d) + wrapper = wrappers_1d[tp.n_active] + if wrapper !== nothing + arr = wrapper::Array{Float64} + # Artificially set wrapper size larger than backing + setfield!(arr, :size, (vec_len + 100,)) + @test_logs (:warn, r"grew beyond backing") rewind!(pool) + else + rewind!(pool) # no wrapper cached yet, skip + end + else + rewind!(pool) + end + end + + # ============================================================================== + # N-D Array mutation detection + # ============================================================================== + + @testset "N-D wrapper mutation detected" begin + pool = _make_pool(true) + checkpoint!(pool) + mat = acquire!(pool, Float64, 10, 10) # 100 elements + mat .= 1.0 + + # Get the 2D wrapper and manually break its MemoryRef + tp = AdaptiveArrayPools.get_typed_pool!(pool, Float64) + wrappers_2d = length(tp.arr_wrappers) >= 2 ? tp.arr_wrappers[2] : nothing + if wrappers_2d !== nothing && tp.n_active <= length(wrappers_2d) + wrapper = wrappers_2d[tp.n_active] + if wrapper !== nothing + arr = wrapper::Array{Float64} + # Artificially set wrapper size to something huge + setfield!(arr, :size, (1000, 1000)) + @test_logs (:warn, r"grew beyond backing") rewind!(pool) + else + rewind!(pool) + end + else + rewind!(pool) + end + end + + # ============================================================================== + # BitTypedPool: chunks reallocation detection + # ============================================================================== + + @testset "resize! detected (BitTypedPool)" begin + pool = _make_pool(true) + checkpoint!(pool) + bv = acquire!(pool, Bit, 64) + bv .= true + + # Resize far beyond → wrapper length diverges from backing + # (BitVector resize! grows chunks in-place, so chunks identity stays the same; + # detection is via wrapper.len > backing.len, not chunks reallocation) + resize!(bv, 100_000) + + @test_logs (:warn, r"grew beyond backing") rewind!(pool) + end + + @testset "no mutation → no warning (BitTypedPool)" begin + pool = _make_pool(true) + checkpoint!(pool) + bv = acquire!(pool, Bit, 100) + bv .= false # element mutation is fine + + @test_logs rewind!(pool) + end + + # ============================================================================== + # Multiple slots: only first mutation triggers warning + # ============================================================================== + + @testset "multiple slots: one warning per rewind" begin + pool = _make_pool(true) + checkpoint!(pool) + v1 = acquire!(pool, Float64, 10) + v2 = acquire!(pool, Float64, 10) + resize!(v1, 10_000) + resize!(v2, 10_000) + + # Should only warn once (first detected mutation), not twice + @test_logs (:warn, r"resize!.*reallocation") rewind!(pool) + end +end