Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,15 @@ permissions:
jobs:
test:
runs-on: ubuntu-24.04
env:
VMODULES: ${{ github.workspace }}/modules
defaults:
run:
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
Expand Down Expand Up @@ -57,6 +64,7 @@ jobs:
v run examples/linear_allocator
v run examples/ring_allocator
v run examples/buddy_allocator
v run examples/concurrent_allocators

sanitizers:
name: Sanitizers / Linux
Expand All @@ -68,3 +76,5 @@ jobs:
version: 0.5.2
- name: Run AddressSanitizer and UndefinedBehaviorSanitizer
run: ./scripts/run_sanitizers.sh
- name: Run ThreadSanitizer on concurrent allocators
run: ./scripts/run_thread_sanitizer.sh
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
/generic_pool
/*_test
**/.*.v3cc.*/
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@ All notable changes to this project will be documented in this file.

## Unreleased

## 1.2.0 - 2026-09-12

- Make the published VPM package the primary installation path and retain
manual source-checkout instructions for contributors.
- Add a reproducible Clang AddressSanitizer and UndefinedBehaviorSanitizer test
gate for the allocator suite.
- Add an optional `antono2.memory.concurrent` submodule with synchronized range
and buddy allocators, multithreaded contention coverage, a runnable example,
and a ThreadSanitizer gate.

## 1.1.0 - 2026-09-11

Expand Down
60 changes: 54 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ objects and managing bounded memory and resource ranges in V.

The library includes checked slot and object pools plus range, linear, and ring
allocators, along with a power-of-two buddy allocator for specialized arenas.
Each implementation is dependency-free and accompanied by a
directly runnable example. Optional Vulkan suballocation examples can build on
the same core without making the general-purpose module Vulkan-specific.
An optional concurrent submodule adds synchronized range and buddy variants.
Each implementation is dependency-free and accompanied by a directly runnable
example. Optional Vulkan suballocation examples can build on the same core
without making the general-purpose module Vulkan-specific.
The allocator test suite and public-import examples run on Linux, macOS, and
Windows.

Expand Down Expand Up @@ -53,12 +54,15 @@ they are ready to update their imports.
| `LinearAllocator` | A whole batch shares one lifetime, such as frame or request scratch data | All at once with `reset()` | Individual ranges cannot be released |
| `RingAllocator` | Allocations are retired in the same order they are created | FIFO | Out-of-order release is rejected |
| `BuddyAllocator` | Power-of-two splitting and recursive coalescing suit the arena | Any order | Requests consume rounded-up blocks and tree metadata |
| `concurrent.RangeAllocator` | Multiple threads share first-fit allocation metadata | Any order | Locking serializes mutations |
| `concurrent.BuddyAllocator` | Multiple threads share buddy-allocation metadata | Any order | Locking serializes mutations |

The allocators manage values or numeric ranges; they do not allocate, map, or
free an operating-system or GPU resource. Create the backing resource once,
use returned offsets or handles to address it, and destroy the backing resource
only after its allocations are no longer live. None of the types is internally
synchronized.
only after its allocations are no longer live. The core types are not
internally synchronized; use the optional `antono2.memory.concurrent` variants
when allocator metadata is shared between threads.

## Slot pool

Expand Down Expand Up @@ -260,6 +264,43 @@ Unlike `RangeAllocator`, the buddy allocator trades internal fragmentation for
bounded tree depth and automatic recursive coalescing. Statistics report both
payload and reserved bytes so that tradeoff remains visible.

## Concurrent allocators

The optional `antono2.memory.concurrent` submodule wraps the range and buddy
allocators with reader/writer mutexes. Mutating operations take an exclusive
lock, while ownership queries and statistics take a shared read lock.

```v
import antono2.memory.concurrent

fn worker(mut arena concurrent.RangeAllocator, done chan bool) {
allocation := arena.allocate(4096, 256) or { panic(err) }
// Use the corresponding backing-memory range here.
assert arena.release(allocation)
done <- true
}

fn main() {
mut arena := concurrent.new_range_allocator(64 * 1024 * 1024)
done := chan bool{cap: 2}
first := spawn worker(mut arena, done)
second := spawn worker(mut arena, done)
_ = <-done
_ = <-done
first.wait()
second.wait()
}
```

Keep and share the pointer returned by the constructor; do not copy the
wrapper. Synchronization protects allocator bookkeeping only. Callers must
still ensure that no thread releases or resets a range while another thread is
using the corresponding host, file, shared-memory, or GPU resource.

The pool APIs intentionally remain unsynchronized because `get()` and
`get_mut()` return pointers whose use can outlive a method-level lock. Safe
concurrent pools require a separate copy- or closure-based access API.

## Vulkan integration

[`antono2.vkmemalloc`](https://github.com/antono2/vulkan_memory_allocator) is a
Expand Down Expand Up @@ -290,6 +331,7 @@ v run examples/range_allocator
v run examples/linear_allocator
v run examples/ring_allocator
v run examples/buddy_allocator
v run examples/concurrent_allocators
```

When working from a source checkout rather than an installed V module, run
Expand Down Expand Up @@ -318,15 +360,21 @@ on the same machine, toolchain, and trace version.
```sh
v fmt -verify .
v vet .
v test .
./scripts/run_tests.sh
./scripts/run_examples.sh
./scripts/run_benchmarks.sh --quick
./scripts/run_sanitizers.sh
./scripts/run_thread_sanitizer.sh
```

The sanitizer gate requires Clang. It checks allocator tests for invalid memory
accesses and undefined behavior; leak detection is disabled because V and its
runtime retain process-lifetime bookkeeping allocations.
ThreadSanitizer separately checks the concurrent allocator contention tests for
data races and unjoined worker threads. That focused gate uses `-gc none`
because V 0.5.2's default Boehm GC signal handler conflicts with
ThreadSanitizer; the allocator wrappers themselves do not depend on a garbage
collector.

## Roadmap

Expand Down
230 changes: 230 additions & 0 deletions concurrent/allocators.v
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
// Module concurrent provides synchronized allocation-metadata wrappers.
//
// The wrappers protect allocator bookkeeping. They do not synchronize access
// to the backing memory represented by returned offsets, and callers must still
// coordinate the lifetime and use of that resource.
module concurrent

import antono2.memory
import sync

// RangeAllocator serializes mutations of a memory.RangeAllocator and permits
// concurrent read-only statistics and ownership checks.
//
// Keep the pointer returned by new_range_allocator() and pass it as `mut` to
// worker threads. Do not copy the wrapper after construction.
pub struct RangeAllocator {
mutex &sync.RwMutex @[required]
mut:
inner &memory.RangeAllocator @[required]
}

// new_range_allocator creates a synchronized first-fit range allocator.
pub fn new_range_allocator(capacity u64) &RangeAllocator {
return &RangeAllocator{
mutex: sync.new_rwmutex()
inner: memory.new_range_allocator(capacity)
}
}

// capacity returns the fixed size of the managed resource.
pub fn (allocator &RangeAllocator) capacity() u64 {
allocator.mutex.rlock()
defer {
allocator.mutex.runlock()
}
return allocator.inner.capacity()
}

// used_bytes returns the sum of all live allocation sizes.
pub fn (allocator &RangeAllocator) used_bytes() u64 {
allocator.mutex.rlock()
defer {
allocator.mutex.runlock()
}
return allocator.inner.used_bytes()
}

// free_bytes returns the number of bytes not currently allocated.
pub fn (allocator &RangeAllocator) free_bytes() u64 {
allocator.mutex.rlock()
defer {
allocator.mutex.runlock()
}
return allocator.inner.free_bytes()
}

// allocation_count returns the number of live allocations.
pub fn (allocator &RangeAllocator) allocation_count() int {
allocator.mutex.rlock()
defer {
allocator.mutex.runlock()
}
return allocator.inner.allocation_count()
}

// allocate reserves one aligned range while holding the write lock.
pub fn (mut allocator RangeAllocator) allocate(size u64, alignment u64) !memory.RangeAllocation {
allocator.mutex.lock()
defer {
allocator.mutex.unlock()
}
return allocator.inner.allocate(size, alignment)
}

// contains reports whether allocation is live and belongs to this allocator.
pub fn (allocator &RangeAllocator) contains(allocation memory.RangeAllocation) bool {
allocator.mutex.rlock()
defer {
allocator.mutex.runlock()
}
return allocator.inner.contains(allocation)
}

// release returns a live allocation to the free-range set.
pub fn (mut allocator RangeAllocator) release(allocation memory.RangeAllocation) bool {
allocator.mutex.lock()
defer {
allocator.mutex.unlock()
}
return allocator.inner.release(allocation)
}

// reset releases every allocation. The caller must ensure that no thread still
// uses the associated backing-memory ranges.
pub fn (mut allocator RangeAllocator) reset() {
allocator.mutex.lock()
defer {
allocator.mutex.unlock()
}
allocator.inner.reset()
}

// stats returns one consistent occupancy and fragmentation snapshot.
pub fn (allocator &RangeAllocator) stats() memory.RangeStats {
allocator.mutex.rlock()
defer {
allocator.mutex.runlock()
}
return allocator.inner.stats()
}

// BuddyAllocator serializes mutations of a memory.BuddyAllocator and permits
// concurrent read-only statistics and ownership checks.
//
// Keep the pointer returned by new_buddy_allocator() and pass it as `mut` to
// worker threads. Do not copy the wrapper after construction.
pub struct BuddyAllocator {
mutex &sync.RwMutex @[required]
mut:
inner &memory.BuddyAllocator @[required]
}

// new_buddy_allocator creates a synchronized power-of-two buddy allocator.
pub fn new_buddy_allocator(capacity u64, minimum_block_size u64) !&BuddyAllocator {
inner := memory.new_buddy_allocator(capacity, minimum_block_size)!
return &BuddyAllocator{
mutex: sync.new_rwmutex()
inner: inner
}
}

// capacity returns the fixed size of the managed arena.
pub fn (allocator &BuddyAllocator) capacity() u64 {
allocator.mutex.rlock()
defer {
allocator.mutex.runlock()
}
return allocator.inner.capacity()
}

// min_block_size returns the smallest block this allocator can reserve.
pub fn (allocator &BuddyAllocator) min_block_size() u64 {
allocator.mutex.rlock()
defer {
allocator.mutex.runlock()
}
return allocator.inner.min_block_size()
}

// used_bytes returns bytes reserved by live buddy blocks.
pub fn (allocator &BuddyAllocator) used_bytes() u64 {
allocator.mutex.rlock()
defer {
allocator.mutex.runlock()
}
return allocator.inner.used_bytes()
}

// payload_bytes returns the sum of requested live allocation sizes.
pub fn (allocator &BuddyAllocator) payload_bytes() u64 {
allocator.mutex.rlock()
defer {
allocator.mutex.runlock()
}
return allocator.inner.payload_bytes()
}

// free_bytes returns capacity not reserved by live buddy blocks.
pub fn (allocator &BuddyAllocator) free_bytes() u64 {
allocator.mutex.rlock()
defer {
allocator.mutex.runlock()
}
return allocator.inner.free_bytes()
}

// allocation_count returns the number of live allocations.
pub fn (allocator &BuddyAllocator) allocation_count() int {
allocator.mutex.rlock()
defer {
allocator.mutex.runlock()
}
return allocator.inner.allocation_count()
}

// allocate reserves one buddy block while holding the write lock.
pub fn (mut allocator BuddyAllocator) allocate(size u64, alignment u64) !memory.BuddyAllocation {
allocator.mutex.lock()
defer {
allocator.mutex.unlock()
}
return allocator.inner.allocate(size, alignment)
}

// contains reports whether allocation is live and belongs to this allocator.
pub fn (allocator &BuddyAllocator) contains(allocation memory.BuddyAllocation) bool {
allocator.mutex.rlock()
defer {
allocator.mutex.runlock()
}
return allocator.inner.contains(allocation)
}

// release returns a live block and recursively coalesces free buddies.
pub fn (mut allocator BuddyAllocator) release(allocation memory.BuddyAllocation) bool {
allocator.mutex.lock()
defer {
allocator.mutex.unlock()
}
return allocator.inner.release(allocation)
}

// reset releases every allocation. The caller must ensure that no thread still
// uses the associated backing-memory ranges.
pub fn (mut allocator BuddyAllocator) reset() {
allocator.mutex.lock()
defer {
allocator.mutex.unlock()
}
allocator.inner.reset()
}

// stats returns one consistent occupancy and fragmentation snapshot.
pub fn (allocator &BuddyAllocator) stats() memory.BuddyStats {
allocator.mutex.rlock()
defer {
allocator.mutex.runlock()
}
return allocator.inner.stats()
}
Loading