From abb57fb0c0187fa751b88c4a086d5d60c82eb7eb Mon Sep 17 00:00:00 2001 From: antono2 Date: Sat, 12 Sep 2026 12:48:09 +0200 Subject: [PATCH] Harden allocator correctness and stability --- .github/workflows/benchmark-baselines.yml | 42 ++++ .github/workflows/ci.yml | 11 +- CHANGELOG.md | 16 ++ README.md | 41 +++- STABILITY.md | 53 ++++++ benchmarks/main.v | 222 ++++++++++++++++++++-- buddy_allocator_test.v | 168 ++++++++++++++++ range_allocator_test.v | 30 +++ ring_allocator_test.v | 211 ++++++++++++++++++++ synchronized_allocators_test.v | 98 ++++++++++ v.mod | 2 +- 11 files changed, 871 insertions(+), 23 deletions(-) create mode 100644 .github/workflows/benchmark-baselines.yml create mode 100644 STABILITY.md diff --git a/.github/workflows/benchmark-baselines.yml b/.github/workflows/benchmark-baselines.yml new file mode 100644 index 0000000..aa177b2 --- /dev/null +++ b/.github/workflows/benchmark-baselines.yml @@ -0,0 +1,42 @@ +name: Benchmark baselines + +on: + push: + tags: ['v*'] + workflow_dispatch: + +permissions: + contents: read + +jobs: + benchmark: + name: Benchmark / ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, macos-14, windows-2022] + runs-on: ${{ matrix.os }} + env: + VMODULES: ${{ github.workspace }}/modules + defaults: + run: + shell: bash + working-directory: modules/antono2/memory + steps: + - uses: actions/checkout@v7 + with: + path: modules/antono2/memory + - uses: prantlf/setup-v-action@v4 + with: + version: 0.5.2 + - name: Record optimized benchmark baseline + run: | + v -prod run benchmarks 250000 \ + | tee benchmark-results-${{ runner.os }}.txt + - name: Upload benchmark baseline + uses: actions/upload-artifact@v4 + with: + name: memory-${{ github.ref_name }}-${{ runner.os }} + path: modules/antono2/memory/benchmark-results-${{ runner.os }}.txt + if-no-files-found: error + retention-days: 90 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9fa7de6..10fcb3c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,12 +28,21 @@ jobs: run: v fmt -verify . - name: Vet run: v vet . + - name: Validate public API documentation + run: v doc -f none -m . - name: Test run: v test . - name: Run examples run: ./scripts/run_examples.sh - name: Run deterministic benchmark smoke test - run: ./scripts/run_benchmarks.sh --quick + run: ./scripts/run_benchmarks.sh --quick | tee benchmark-smoke.txt + - name: Upload benchmark smoke results + uses: actions/upload-artifact@v4 + with: + name: memory-benchmark-smoke-${{ github.run_id }} + path: modules/antono2/memory/benchmark-smoke.txt + if-no-files-found: error + retention-days: 14 platform-tests: name: Test / ${{ matrix.os }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 9419782..c5a7dd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file. ## Unreleased +## 1.4.0 - 2026-09-12 + +- Add independent block and FIFO reference models for buddy and ring allocation, + including 20,000-operation deterministic comparison traces. +- Prove synchronized range and buddy allocators match their plain counterparts + across deterministic success, failure, offset, and statistics traces. +- Cover zero-capacity state, failed-allocation atomicity, and allocation-ID + wraparound without reusing zero or a live identifier. +- Expand benchmark suite v3 with synchronized-wrapper comparisons and 1, 2, 4, + and 8-worker contention workloads. +- Store benchmark smoke results as CI artifacts and record optimized Linux, + macOS, and Windows baseline artifacts for release tags. +- Document the stable 1.x API surface, ownership-token boundaries, supported V + compiler, algorithmic complexity, and synchronization guarantees. +- Validate root-module API documentation generation in CI. + ## 1.3.1 - 2026-09-12 - Remove the short-lived `antono2.memory.concurrent` compatibility submodule diff --git a/README.md b/README.md index 786deb9..86cda2e 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,9 @@ and the `antono2/memory` installed directory. Projects that still import `generic_pool` should pin the 0.2.0 release until they are ready to update their imports. +See [API stability and support](STABILITY.md) for the 1.x compatibility, +threading, ownership-token, and compiler-support guarantees. + ## Choosing an allocator | Type | Use it when | Release order | Main tradeoff | @@ -64,6 +67,22 @@ only after its allocations are no longer live. The lightweight core types are not internally synchronized; use the explicitly named `Synchronized` variants when allocator metadata is shared between threads. +## Complexity and synchronization + +| Type | Allocate/acquire | Release/reset | Query notes | +| --- | --- | --- | --- | +| `SlotPool[T]` | O(1) | O(1) release; O(capacity) clear | O(1) lookup; external synchronization required | +| `ObjectPool[T]` | O(1) plus factory | O(1) plus reset callback; O(capacity) release-all | External synchronization required | +| `RangeAllocator` | O(free ranges) | O(free ranges) | Statistics scan free ranges; external synchronization required | +| `LinearAllocator` | O(1) | O(1) whole-arena reset | O(1) queries; external synchronization required | +| `RingAllocator` | O(1) | Amortized O(1) FIFO release | `contains` scans live records; external synchronization required | +| `BuddyAllocator` | O(log(capacity/minimum block)) | O(log(capacity/minimum block)) | Largest-block statistics may scan the buddy tree; external synchronization required | +| `SynchronizedRangeAllocator` | Range cost plus write lock | Range cost plus write lock | Read-only queries use a shared lock | +| `SynchronizedBuddyAllocator` | Buddy cost plus write lock | Buddy cost plus write lock | Read-only queries use a shared lock | + +`f` denotes the current number of free ranges. Callback execution time and +thread scheduling are outside these bounds. + ## Slot pool Create a pool once, insert values until it reaches its fixed capacity, and use @@ -350,12 +369,17 @@ Run the deterministic churn workloads with production compiler optimizations: Pass an operation count to shorten or extend a run, or use `--quick` for the CI smoke workload. The harness covers slot and object reuse, fragmented first-fit -ranges, power-of-two buddy allocation, linear allocate/reset cycles, and FIFO -ring streaming. Range and buddy allocation replay the same bounded request and -release trace, verified by a trace hash, and report successful allocations, -failures, peak occupancy, and fragmentation alongside timing. The harness -deliberately enforces no universal performance threshold; compare results only -on the same machine, toolchain, and trace version. +ranges, power-of-two buddy allocation, linear allocate/reset cycles, FIFO ring +streaming, synchronized-wrapper overhead, and synchronized range and buddy +contention with 1, 2, 4, and 8 workers. Plain and synchronized allocators replay +the same bounded request and release trace, verified by trace hashes and result +checksums. + +CI stores the quick Linux result as a downloadable text artifact. Each release +tag also records optimized Linux, macOS, and Windows baselines. These results +are diagnostic: the project enforces correctness and trace equivalence, not a +universal timing threshold. Compare timings only on equivalent machines, +toolchains, operation counts, and trace versions. ## Verify @@ -380,8 +404,9 @@ collector. ## Roadmap -- benchmark baselines across representative machines and V compiler versions -- specialized allocation policies driven by benchmark results +- realistic allocation traces collected from downstream integrations +- specialized allocation policies only when those traces show a measurable need +- safe lease- or closure-based synchronized pool access if concrete use cases require it - additional integrations that keep platform APIs outside the core module ## License diff --git a/STABILITY.md b/STABILITY.md new file mode 100644 index 0000000..1f38dce --- /dev/null +++ b/STABILITY.md @@ -0,0 +1,53 @@ +# API stability and support + +Beginning with v1.4.0, `antono2.memory` follows semantic versioning for its +public root-module API. + +## Stable surface + +The following are compatibility commitments within the 1.x release series: + +- public type, function, and method names in `antono2.memory` +- public allocation and statistics fields +- documented allocation order, release order, validation, and reset behavior +- acceptance of every configuration and request described as valid +- rejection of stale, forged, foreign, or out-of-order records where documented + +New types, methods, and fields may be added in a minor release. Removing or +incompatibly changing stable behavior requires a new major release. When +practical, an API scheduled for removal will first be deprecated for at least +one minor release. + +## Not a serialized or binary interface + +Handles and allocation records are process-local capability values. Their +private ownership and generation fields must not be forged, persisted, sent to +another process, or reconstructed from public offsets and sizes. Struct memory +layout, private fields, internal data structures, allocation identifiers, and +exact error wording are implementation details. + +Use error propagation for allocation failure; do not branch on the complete +error string. Public statistics are diagnostic snapshots and do not reserve or +guarantee a later allocation. + +## Threading + +`SlotPool`, `ObjectPool`, `RangeAllocator`, `LinearAllocator`, `RingAllocator`, +and `BuddyAllocator` require external synchronization when shared between +threads. `SynchronizedRangeAllocator` and `SynchronizedBuddyAllocator` protect +their allocation metadata with reader/writer mutexes. + +Synchronization does not protect the backing host memory, mapped file, shared +memory, Vulkan memory, or other resource represented by an offset. Applications +must coordinate use, release, reset, and destruction of that resource. + +Pool accessors return pointers whose use may outlive a method call. For that +reason, the package does not claim that wrapping individual pool methods in a +mutex would make pointer access thread-safe. + +## Supported compiler + +The minimum tested compiler for v1.4.x is V 0.5.2. Every release is tested on +Linux, macOS, and Windows with that compiler. V itself is evolving, so support +for later compiler releases is verified and adjusted in subsequent package +releases rather than assumed. diff --git a/benchmarks/main.v b/benchmarks/main.v index fb26f65..fd888a5 100644 --- a/benchmarks/main.v +++ b/benchmarks/main.v @@ -160,8 +160,7 @@ fn benchmark_object_pool(operations int) BenchmarkResult { fn benchmark_range_allocator(operations int) BenchmarkResult { mut allocator := memory.new_range_allocator(1024 * 1024) mut trace := new_allocation_trace() - mut allocations := []memory.RangeAllocation{len: allocator_trace_max_live} - mut allocated := []bool{len: allocator_trace_max_live} + mut allocations := []?memory.RangeAllocation{len: allocator_trace_max_live} mut checksum := u64(0) mut allocation_attempts := 0 mut allocation_failures := 0 @@ -174,7 +173,6 @@ fn benchmark_range_allocator(operations int) BenchmarkResult { allocation_attempts++ if allocation := allocator.allocate(operation.size, operation.alignment) { allocations[operation.token] = allocation - allocated[operation.token] = true if allocator.used_bytes() > peak_used { peak_used = allocator.used_bytes() } @@ -182,13 +180,12 @@ fn benchmark_range_allocator(operations int) BenchmarkResult { allocation_failures++ } } else { - if allocated[operation.token] { - allocation := allocations[operation.token] + if allocation := allocations[operation.token] { checksum += allocation.offset if !allocator.release(allocation) { panic('live range allocation was rejected') } - allocated[operation.token] = false + allocations[operation.token] = none releases++ } } @@ -207,8 +204,7 @@ fn benchmark_range_allocator(operations int) BenchmarkResult { fn benchmark_buddy_allocator(operations int) BenchmarkResult { mut allocator := memory.new_buddy_allocator(1024 * 1024, 16) or { panic(err) } mut trace := new_allocation_trace() - mut allocations := []memory.BuddyAllocation{len: allocator_trace_max_live} - mut allocated := []bool{len: allocator_trace_max_live} + mut allocations := []?memory.BuddyAllocation{len: allocator_trace_max_live} mut checksum := u64(0) mut allocation_attempts := 0 mut allocation_failures := 0 @@ -220,18 +216,16 @@ fn benchmark_buddy_allocator(operations int) BenchmarkResult { allocation_attempts++ if allocation := allocator.allocate(operation.size, operation.alignment) { allocations[operation.token] = allocation - allocated[operation.token] = true } else { allocation_failures++ } } else { - if allocated[operation.token] { - allocation := allocations[operation.token] + if allocation := allocations[operation.token] { checksum += allocation.offset if !allocator.release(allocation) { panic('live buddy allocation was rejected') } - allocated[operation.token] = false + allocations[operation.token] = none releases++ } } @@ -247,6 +241,190 @@ fn benchmark_buddy_allocator(operations int) BenchmarkResult { } } +fn benchmark_synchronized_range_allocator(operations int) BenchmarkResult { + mut allocator := memory.new_synchronized_range_allocator(1024 * 1024) + mut trace := new_allocation_trace() + mut allocations := []?memory.RangeAllocation{len: allocator_trace_max_live} + mut checksum := u64(0) + mut allocation_attempts := 0 + mut allocation_failures := 0 + mut releases := 0 + mut peak_used := u64(0) + start := time.sys_mono_now() + for _ in 0 .. operations { + operation := trace.next() + if operation.is_allocate { + allocation_attempts++ + if allocation := allocator.allocate(operation.size, operation.alignment) { + allocations[operation.token] = allocation + if allocator.used_bytes() > peak_used { + peak_used = allocator.used_bytes() + } + } else { + allocation_failures++ + } + } else if allocation := allocations[operation.token] { + checksum += allocation.offset + if !allocator.release(allocation) { + panic('live synchronized range allocation was rejected') + } + allocations[operation.token] = none + releases++ + } + } + stats := allocator.stats() + return BenchmarkResult{ + name: 'range synchronized' + operations: operations + elapsed_ns: time.sys_mono_now() - start + checksum: checksum + stats.used + detail: 'trace=v2 allocations=${allocation_attempts - allocation_failures}/${allocation_attempts} failed=${allocation_failures} releases=${releases} live=${stats.allocation_count} used=${stats.used} peak=${peak_used} free_ranges=${stats.free_range_count} largest_free=${stats.largest_free_range}' + trace_hash: trace.hash + } +} + +fn benchmark_synchronized_buddy_allocator(operations int) BenchmarkResult { + mut allocator := memory.new_synchronized_buddy_allocator(1024 * 1024, 16) or { panic(err) } + mut trace := new_allocation_trace() + mut allocations := []?memory.BuddyAllocation{len: allocator_trace_max_live} + mut checksum := u64(0) + mut allocation_attempts := 0 + mut allocation_failures := 0 + mut releases := 0 + start := time.sys_mono_now() + for _ in 0 .. operations { + operation := trace.next() + if operation.is_allocate { + allocation_attempts++ + if allocation := allocator.allocate(operation.size, operation.alignment) { + allocations[operation.token] = allocation + } else { + allocation_failures++ + } + } else if allocation := allocations[operation.token] { + checksum += allocation.offset + if !allocator.release(allocation) { + panic('live synchronized buddy allocation was rejected') + } + allocations[operation.token] = none + releases++ + } + } + stats := allocator.stats() + return BenchmarkResult{ + name: 'buddy synchronized' + operations: operations + elapsed_ns: time.sys_mono_now() - start + checksum: checksum + stats.reserved + detail: 'trace=v2 allocations=${allocation_attempts - allocation_failures}/${allocation_attempts} failed=${allocation_failures} releases=${releases} live=${stats.allocation_count} payload=${stats.payload} reserved=${stats.reserved} internal=${stats.internal_fragmentation} peak=${stats.peak_reserved} largest_free=${stats.largest_free_block}' + trace_hash: trace.hash + } +} + +fn synchronized_range_benchmark_worker(mut allocator memory.SynchronizedRangeAllocator, worker int, operations int, done chan u64) { + mut random_source := BenchmarkRandom{ + state: u32(0x51a2cafe) ^ u32(worker) * u32(0x1f123bb5) + } + mut checksum := u64(0) + for _ in 0 .. operations { + random := random_source.next() + size := u64(16 + (random >> 12) % 2033) + alignment := u64(1) << u32((random >> 28) % 9) + allocation := allocator.allocate(size, alignment) or { panic(err) } + checksum += allocation.offset + allocation.size + if !allocator.release(allocation) { + panic('live synchronized range allocation was rejected by worker ${worker}') + } + } + done <- checksum +} + +fn synchronized_buddy_benchmark_worker(mut allocator memory.SynchronizedBuddyAllocator, worker int, operations int, done chan u64) { + mut random_source := BenchmarkRandom{ + state: u32(0xbaddcafe) ^ u32(worker) * u32(0x1f123bb5) + } + mut checksum := u64(0) + for _ in 0 .. operations { + random := random_source.next() + size := u64(16 + (random >> 12) % 2033) + alignment := u64(1) << u32((random >> 28) % 9) + allocation := allocator.allocate(size, alignment) or { panic(err) } + checksum += allocation.offset + allocation.block_size + if !allocator.release(allocation) { + panic('live synchronized buddy allocation was rejected by worker ${worker}') + } + } + done <- checksum +} + +fn worker_operation_count(total_operations int, workers int, worker int) int { + base := total_operations / workers + remainder := total_operations % workers + return base + if worker < remainder { + 1 + } else { + 0 + } +} + +fn benchmark_synchronized_range_contention(operations int, workers int) BenchmarkResult { + mut allocator := memory.new_synchronized_range_allocator(1024 * 1024) + done := chan u64{cap: workers} + mut threads := []thread{cap: workers} + start := time.sys_mono_now() + for worker in 0 .. workers { + worker_operations := worker_operation_count(operations, workers, worker) + threads << spawn synchronized_range_benchmark_worker(mut allocator, worker, + worker_operations, done) + } + mut checksum := u64(0) + for _ in 0 .. workers { + checksum += <-done + } + threads.wait() + elapsed_ns := time.sys_mono_now() - start + stats := allocator.stats() + if stats.used != 0 || stats.allocation_count != 0 { + panic('synchronized range contention benchmark leaked allocations') + } + return BenchmarkResult{ + name: 'range synchronized ${workers} workers' + operations: operations + elapsed_ns: elapsed_ns + checksum: checksum + detail: 'workers=${workers} final_used=${stats.used}' + } +} + +fn benchmark_synchronized_buddy_contention(operations int, workers int) BenchmarkResult { + mut allocator := memory.new_synchronized_buddy_allocator(1024 * 1024, 16) or { panic(err) } + done := chan u64{cap: workers} + mut threads := []thread{cap: workers} + start := time.sys_mono_now() + for worker in 0 .. workers { + worker_operations := worker_operation_count(operations, workers, worker) + threads << spawn synchronized_buddy_benchmark_worker(mut allocator, worker, + worker_operations, done) + } + mut checksum := u64(0) + for _ in 0 .. workers { + checksum += <-done + } + threads.wait() + elapsed_ns := time.sys_mono_now() - start + stats := allocator.stats() + if stats.reserved != 0 || stats.allocation_count != 0 { + panic('synchronized buddy contention benchmark leaked allocations') + } + return BenchmarkResult{ + name: 'buddy synchronized ${workers} workers' + operations: operations + elapsed_ns: elapsed_ns + checksum: checksum + detail: 'workers=${workers} final_reserved=${stats.reserved}' + } +} + fn benchmark_linear_allocator(operations int) BenchmarkResult { mut allocator := memory.new_linear_allocator(1024 * 1024) mut random_source := BenchmarkRandom{ @@ -338,7 +516,7 @@ fn main() { eprintln('operation count must be greater than zero') exit(2) } - println('deterministic allocator benchmark: operations=${operations}, seed set=v2') + println('allocator benchmark: operations=${operations}, suite=v3, trace=v2') print_result(benchmark_slot_pool(operations)) print_result(benchmark_object_pool(operations)) range_result := benchmark_range_allocator(operations) @@ -348,6 +526,24 @@ fn main() { } print_result(range_result) print_result(buddy_result) + synchronized_range_result := benchmark_synchronized_range_allocator(operations) + synchronized_buddy_result := benchmark_synchronized_buddy_allocator(operations) + if synchronized_range_result.trace_hash != range_result.trace_hash + || synchronized_range_result.checksum != range_result.checksum { + panic('synchronized range allocator diverged from the plain trace') + } + if synchronized_buddy_result.trace_hash != buddy_result.trace_hash + || synchronized_buddy_result.checksum != buddy_result.checksum { + panic('synchronized buddy allocator diverged from the plain trace') + } + print_result(synchronized_range_result) + print_result(synchronized_buddy_result) print_result(benchmark_linear_allocator(operations)) print_result(benchmark_ring_allocator(operations)) + for workers in [1, 2, 4, 8] { + print_result(benchmark_synchronized_range_contention(operations, workers)) + } + for workers in [1, 2, 4, 8] { + print_result(benchmark_synchronized_buddy_contention(operations, workers)) + } } diff --git a/buddy_allocator_test.v b/buddy_allocator_test.v index 9bc68d5..1c1154d 100644 --- a/buddy_allocator_test.v +++ b/buddy_allocator_test.v @@ -159,3 +159,171 @@ fn assert_buddy_allocator_invariants(allocator &BuddyAllocator, active []BuddyAl assert stats.reserved == reserved assert stats.payload == payload } + +struct BuddyModelAllocation { + allocation BuddyAllocation + first_block int + block_count int +} + +fn model_buddy_block_count(size int, alignment int, minimum_block_size int) int { + mut required := size + if alignment > required { + required = alignment + } + mut block_size := minimum_block_size + for block_size < required { + block_size *= 2 + } + return block_size / minimum_block_size +} + +fn model_buddy_first_free_block(occupied []bool, block_count int) ?int { + if block_count <= 0 || block_count > occupied.len { + return none + } + mut first := 0 + for first + block_count <= occupied.len { + mut available := true + for index in first .. first + block_count { + if occupied[index] { + available = false + break + } + } + if available { + return first + } + first += block_count + } + return none +} + +fn model_buddy_largest_free_block(occupied []bool, minimum_block_size int) u64 { + mut block_count := occupied.len + for block_count > 0 { + mut first := 0 + for first < occupied.len { + mut available := true + for index in first .. first + block_count { + if occupied[index] { + available = false + break + } + } + if available { + return u64(block_count * minimum_block_size) + } + first += block_count + } + block_count /= 2 + } + return 0 +} + +fn test_buddy_allocator_matches_independent_block_model() { + capacity := 512 + minimum_block_size := 8 + mut allocator := new_buddy_allocator(u64(capacity), u64(minimum_block_size)) or { panic(err) } + mut occupied := []bool{len: capacity / minimum_block_size} + mut active := []BuddyModelAllocation{} + mut state := u32(0xb10c5eed) + mut model_reserved := u64(0) + mut model_peak_reserved := u64(0) + + for step in 0 .. 20_000 { + state = state * 1_664_525 + 1_013_904_223 + if active.len > 0 && state % 3 == 0 { + index := int((state >> 8) % u32(active.len)) + record := active[index] + assert allocator.release(record.allocation) + for block in record.first_block .. record.first_block + record.block_count { + assert occupied[block] + occupied[block] = false + } + model_reserved -= u64(record.block_count * minimum_block_size) + active.delete(index) + } else { + size := 1 + int((state >> 12) % 127) + alignment := 1 << int((state >> 28) % 9) + block_count := model_buddy_block_count(size, alignment, minimum_block_size) + before := allocator.stats() + if first_block := model_buddy_first_free_block(occupied, block_count) { + allocation := allocator.allocate(u64(size), u64(alignment)) or { + panic('model found block ${first_block}, allocator failed: ${err}') + } + assert allocation.offset == u64(first_block * minimum_block_size) + assert allocation.block_size == u64(block_count * minimum_block_size) + for block in first_block .. first_block + block_count { + assert !occupied[block] + occupied[block] = true + } + active << BuddyModelAllocation{ + allocation: allocation + first_block: first_block + block_count: block_count + } + model_reserved += u64(block_count * minimum_block_size) + if model_reserved > model_peak_reserved { + model_peak_reserved = model_reserved + } + } else { + if allocation := allocator.allocate(u64(size), u64(alignment)) { + assert false, 'allocator returned unexpected block at ${allocation.offset}' + } + assert allocator.stats() == before + } + } + + if step % 100 == 0 { + mut occupied_reserved := u64(0) + for is_occupied in occupied { + if is_occupied { + occupied_reserved += u64(minimum_block_size) + } + } + assert occupied_reserved == model_reserved + mut model_payload := u64(0) + for record in active { + model_payload += record.allocation.size + } + stats := allocator.stats() + assert stats.reserved == model_reserved + assert stats.payload == model_payload + assert stats.internal_fragmentation == model_reserved - model_payload + assert stats.free == u64(capacity) - model_reserved + assert stats.peak_reserved == model_peak_reserved + assert stats.allocation_count == active.len + assert stats.largest_free_block == model_buddy_largest_free_block(occupied, + minimum_block_size) + } + } + + for record in active { + assert allocator.release(record.allocation) + for block in record.first_block .. record.first_block + record.block_count { + assert occupied[block] + occupied[block] = false + } + } + final_stats := allocator.stats() + assert final_stats.reserved == 0 + assert final_stats.payload == 0 + assert final_stats.peak_reserved == model_peak_reserved + assert final_stats.largest_free_block == u64(capacity) + assert model_buddy_largest_free_block(occupied, minimum_block_size) == u64(capacity) +} + +fn test_buddy_allocator_allocation_ids_skip_zero_and_live_ids() { + mut allocator := new_buddy_allocator(16, 4) or { panic(err) } + first := allocator.allocate(4, 1) or { panic(err) } + allocator.next_id = max_u64 + wrapped := allocator.allocate(4, 1) or { panic(err) } + after_wrap := allocator.allocate(4, 1) or { panic(err) } + + assert first.id == 1 + assert wrapped.id == max_u64 + assert after_wrap.id == 2 + assert first.id != wrapped.id + assert wrapped.id != after_wrap.id +} diff --git a/range_allocator_test.v b/range_allocator_test.v index 5f5f896..8f0277c 100644 --- a/range_allocator_test.v +++ b/range_allocator_test.v @@ -131,6 +131,36 @@ fn test_range_allocator_handles_alignment_overflow() { assert allocator.stats().largest_free_range == max_u64 } +fn test_zero_capacity_range_allocator_reports_consistent_stats() { + mut allocator := new_range_allocator(0) + assert allocator.capacity() == 0 + assert allocator.used_bytes() == 0 + assert allocator.free_bytes() == 0 + assert allocator.allocation_count() == 0 + assert allocator.stats() == RangeStats{} + before := allocator.stats() + if _ := allocator.allocate(1, 1) { + assert false, 'zero-capacity allocator must reject allocations' + } else { + assert err.msg().contains('exhausted') + } + assert allocator.stats() == before +} + +fn test_range_allocator_allocation_ids_skip_zero_and_live_ids() { + mut allocator := new_range_allocator(4) + first := allocator.allocate(1, 1) or { panic(err) } + allocator.next_id = max_u64 + wrapped := allocator.allocate(1, 1) or { panic(err) } + after_wrap := allocator.allocate(1, 1) or { panic(err) } + + assert first.id == 1 + assert wrapped.id == max_u64 + assert after_wrap.id == 2 + assert first.id != wrapped.id + assert wrapped.id != after_wrap.id +} + fn test_range_allocator_deterministic_stress() { capacity := u64(4096) mut allocator := new_range_allocator(capacity) diff --git a/ring_allocator_test.v b/ring_allocator_test.v index 863df82..09d79dc 100644 --- a/ring_allocator_test.v +++ b/ring_allocator_test.v @@ -192,3 +192,214 @@ fn assert_ring_allocator_invariants(allocator &RingAllocator, active []RingAlloc } } } + +struct RingModelCandidate { + offset int + reserved_start int + reserved_size int +} + +struct RingModelRecord { + allocation RingAllocation + reserved_start int + reserved_size int +} + +struct RingReferenceModel { + capacity int +mut: + head int + tail int + used int + payload int + peak_used int + occupied []bool + records []RingModelRecord +} + +fn new_ring_reference_model(capacity int) RingReferenceModel { + return RingReferenceModel{ + capacity: capacity + occupied: []bool{len: capacity} + } +} + +fn align_int_forward(value int, alignment int) int { + return (value + alignment - 1) / alignment * alignment +} + +fn (model &RingReferenceModel) allocation_candidate(size int, alignment int) ?RingModelCandidate { + if size <= 0 || alignment <= 0 || model.capacity == 0 || size > model.capacity { + return none + } + if model.records.len == 0 { + return RingModelCandidate{ + offset: 0 + reserved_start: 0 + reserved_size: size + } + } + available := model.capacity - model.used + if size > available { + return none + } + if model.head < model.tail { + aligned_offset := align_int_forward(model.head, alignment) + if aligned_offset <= model.tail && size <= model.tail - aligned_offset { + return RingModelCandidate{ + offset: aligned_offset + reserved_start: model.head + reserved_size: aligned_offset - model.head + size + } + } + return none + } + + aligned_offset := align_int_forward(model.head, alignment) + if aligned_offset <= model.capacity && size <= model.capacity - aligned_offset { + reserved_size := aligned_offset - model.head + size + if reserved_size <= available { + return RingModelCandidate{ + offset: aligned_offset + reserved_start: model.head + reserved_size: reserved_size + } + } + } + wrap_padding := model.capacity - model.head + if wrap_padding + size <= available && size <= model.tail { + return RingModelCandidate{ + offset: 0 + reserved_start: model.head + reserved_size: wrap_padding + size + } + } + return none +} + +fn (mut model RingReferenceModel) commit(allocation RingAllocation, candidate RingModelCandidate) { + for distance in 0 .. candidate.reserved_size { + index := (candidate.reserved_start + distance) % model.capacity + assert !model.occupied[index] + model.occupied[index] = true + } + model.records << RingModelRecord{ + allocation: allocation + reserved_start: candidate.reserved_start + reserved_size: candidate.reserved_size + } + model.head = (candidate.reserved_start + candidate.reserved_size) % model.capacity + model.used += candidate.reserved_size + model.payload += int(allocation.size) + if model.used > model.peak_used { + model.peak_used = model.used + } +} + +fn (mut model RingReferenceModel) release_oldest() RingAllocation { + record := model.records[0] + for distance in 0 .. record.reserved_size { + index := (record.reserved_start + distance) % model.capacity + assert model.occupied[index] + model.occupied[index] = false + } + model.used -= record.reserved_size + model.payload -= int(record.allocation.size) + model.tail = (record.reserved_start + record.reserved_size) % model.capacity + model.records.delete(0) + if model.records.len == 0 { + model.head = 0 + model.tail = 0 + model.used = 0 + model.payload = 0 + } + return record.allocation +} + +fn (model &RingReferenceModel) largest_contiguous_free() int { + if model.records.len == 0 { + return model.capacity + } + if model.used == model.capacity { + return 0 + } + if model.head < model.tail { + return model.tail - model.head + } + end_space := model.capacity - model.head + return if end_space > model.tail { end_space } else { model.tail } +} + +fn assert_ring_matches_reference_model(allocator &RingAllocator, model &RingReferenceModel) { + stats := allocator.stats() + assert stats.capacity == u64(model.capacity) + assert stats.used == u64(model.used) + assert stats.payload == u64(model.payload) + assert stats.padding == u64(model.used - model.payload) + assert stats.free == u64(model.capacity - model.used) + assert stats.peak_used == u64(model.peak_used) + assert stats.allocation_count == model.records.len + assert stats.largest_contiguous_free == u64(model.largest_contiguous_free()) + mut occupied_count := 0 + for is_occupied in model.occupied { + if is_occupied { + occupied_count++ + } + } + assert occupied_count == model.used +} + +fn test_ring_allocator_matches_independent_fifo_model() { + capacity := 127 + alignments := [1, 2, 3, 5, 7, 8, 16, 31] + mut allocator := new_ring_allocator(u64(capacity)) + mut model := new_ring_reference_model(capacity) + mut state := u32(0xf1f0cafe) + + for step in 0 .. 20_000 { + state = state * 1_664_525 + 1_013_904_223 + if model.records.len > 0 && state % 4 == 0 { + allocation := model.release_oldest() + assert allocator.release(allocation) + } else { + size := 1 + int((state >> 12) % 29) + alignment := alignments[int((state >> 24) % u32(alignments.len))] + before := allocator.stats() + if candidate := model.allocation_candidate(size, alignment) { + allocation := allocator.allocate(u64(size), u64(alignment)) or { + panic('model found offset ${candidate.offset}, allocator failed: ${err}') + } + assert allocation.offset == u64(candidate.offset) + model.commit(allocation, candidate) + } else { + if allocation := allocator.allocate(u64(size), u64(alignment)) { + assert false, 'allocator returned unexpected range at ${allocation.offset}' + } + assert allocator.stats() == before + } + } + if step % 50 == 0 { + assert_ring_matches_reference_model(allocator, &model) + } + } + + for model.records.len > 0 { + allocation := model.release_oldest() + assert allocator.release(allocation) + } + assert_ring_matches_reference_model(allocator, &model) +} + +fn test_ring_allocator_allocation_ids_skip_zero_and_live_ids() { + mut allocator := new_ring_allocator(4) + first := allocator.allocate(1, 1) or { panic(err) } + allocator.next_id = max_u64 + wrapped := allocator.allocate(1, 1) or { panic(err) } + after_wrap := allocator.allocate(1, 1) or { panic(err) } + + assert first.id == 1 + assert wrapped.id == max_u64 + assert after_wrap.id == 2 + assert first.id != wrapped.id + assert wrapped.id != after_wrap.id +} diff --git a/synchronized_allocators_test.v b/synchronized_allocators_test.v index 376dac0..dd19f49 100644 --- a/synchronized_allocators_test.v +++ b/synchronized_allocators_test.v @@ -41,3 +41,101 @@ fn test_synchronized_allocators_reset_live_allocations() { assert !buddies.contains(buddy_allocation) assert buddies.free_bytes() == buddies.capacity() } + +struct SynchronizedRangePair { + plain RangeAllocation + locked RangeAllocation +} + +struct SynchronizedBuddyPair { + plain BuddyAllocation + locked BuddyAllocation +} + +fn test_synchronized_range_allocator_matches_plain_trace() { + mut plain := new_range_allocator(4096) + mut locked := new_synchronized_range_allocator(4096) + mut active := []SynchronizedRangePair{} + alignments := [u64(1), 2, 3, 4, 8, 16, 31, 64] + mut state := u32(0x5a11ce55) + + for _ in 0 .. 10_000 { + state = state * 1_664_525 + 1_013_904_223 + if active.len > 0 && state % 3 == 0 { + index := int((state >> 8) % u32(active.len)) + pair := active[index] + assert plain.release(pair.plain) + assert locked.release(pair.locked) + active.delete(index) + } else { + size := u64(1 + (state >> 12) % 193) + alignment := alignments[int((state >> 24) % u32(alignments.len))] + if plain_allocation := plain.allocate(size, alignment) { + locked_allocation := locked.allocate(size, alignment) or { + panic('synchronized range allocator diverged: ${err}') + } + assert locked_allocation.offset == plain_allocation.offset + assert locked_allocation.size == plain_allocation.size + active << SynchronizedRangePair{ + plain: plain_allocation + locked: locked_allocation + } + } else { + if unexpected := locked.allocate(size, alignment) { + assert false, 'synchronized allocator unexpectedly returned ${unexpected.offset}' + } + } + } + assert locked.stats() == plain.stats() + } + + for pair in active { + assert plain.release(pair.plain) + assert locked.release(pair.locked) + } + assert locked.stats() == plain.stats() +} + +fn test_synchronized_buddy_allocator_matches_plain_trace() { + mut plain := new_buddy_allocator(4096, 8) or { panic(err) } + mut locked := new_synchronized_buddy_allocator(4096, 8) or { panic(err) } + mut active := []SynchronizedBuddyPair{} + mut state := u32(0xbaddcafe) + + for _ in 0 .. 10_000 { + state = state * 1_664_525 + 1_013_904_223 + if active.len > 0 && state % 3 == 0 { + index := int((state >> 8) % u32(active.len)) + pair := active[index] + assert plain.release(pair.plain) + assert locked.release(pair.locked) + active.delete(index) + } else { + size := u64(1 + (state >> 12) % 257) + alignment := u64(1) << u32((state >> 28) % 10) + if plain_allocation := plain.allocate(size, alignment) { + locked_allocation := locked.allocate(size, alignment) or { + panic('synchronized buddy allocator diverged: ${err}') + } + assert locked_allocation.offset == plain_allocation.offset + assert locked_allocation.size == plain_allocation.size + assert locked_allocation.block_size == plain_allocation.block_size + active << SynchronizedBuddyPair{ + plain: plain_allocation + locked: locked_allocation + } + } else { + if unexpected := locked.allocate(size, alignment) { + assert false, 'synchronized allocator unexpectedly returned ${unexpected.offset}' + } + } + } + assert locked.stats() == plain.stats() + } + + for pair in active { + assert plain.release(pair.plain) + assert locked.release(pair.locked) + } + assert locked.stats() == plain.stats() +} diff --git a/v.mod b/v.mod index cc8225e..e7f00b7 100644 --- a/v.mod +++ b/v.mod @@ -2,7 +2,7 @@ Module { name: 'antono2.memory' author: 'Anton Oreskin' description: 'Reusable memory pools and allocation algorithms for V' - version: '1.3.1' + version: '1.4.0' license: 'MIT' repo_url: 'https://github.com/antono2/memory' tags: ['V', 'pool', 'allocator', 'memory-management']