memory provides small, documented, and tested building blocks for reusing
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. Explicitly named synchronized range and buddy variants are available from the same root import. 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.
antono2.memory is published on VPM:
v install antono2.memoryFor a source checkout, clone the repository into V's canonical nested module path:
mkdir -p ~/.vmodules/antono2
git clone https://github.com/antono2/memory \
~/.vmodules/antono2/memoryV 0.5.2 requires that nested path for dotted module names.
Then import it using its canonical VPM name:
import antono2.memoryThe canonical VPM and import name is antono2.memory, matching the repository
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 for the 1.x compatibility, threading, ownership-token, and compiler-support guarantees.
| Type | Use it when | Release order | Main tradeoff |
|---|---|---|---|
SlotPool[T] |
You need a fixed number of typed values with stable, checked handles | Any order | Capacity is fixed at construction |
ObjectPool[T] |
Creating a typed resource is expensive and released values can be reset and reused | Any order | A reset callback must restore reusable state |
RangeAllocator |
Requests have arbitrary sizes or non-power-of-two alignments | Any order | First-fit allocation and release scan free ranges |
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 |
SynchronizedRangeAllocator |
Multiple threads share first-fit allocation metadata | Any order | Locking serializes mutations |
SynchronizedBuddyAllocator |
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. The lightweight core types are
not internally synchronized; use the explicitly named Synchronized variants
when allocator metadata is shared between threads.
| 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.
Create a pool once, insert values until it reaches its fixed capacity, and use the returned handle for later access or release:
import antono2.memory
struct Particle {
mut:
x f32
y f32
}
fn main() {
mut particles := memory.new_slot_pool[Particle](128) or { panic(err) }
handle := particles.insert(Particle{x: 10, y: 20}) or { panic(err) }
mut particle := particles.get_mut(handle) or { panic('stale particle handle') }
particle.x += 1
released := particles.release(handle)
assert released
assert particles.get(handle) == none
}insert,get, andreleaseare O(1).- The pool never grows after construction.
- Releasing or clearing a slot invalidates its earlier handles.
- A stale, forged, or already released handle is rejected.
clear()invalidates all active handles without reallocating the pool.- The implementation is not internally synchronized. Externally synchronize access shared between threads.
Pointers returned by get() and get_mut() must not be retained after their
handle is released or the pool is cleared. Doing so bypasses handle validation.
The generation counter can eventually wrap after 4,294,967,295 releases of the same slot. Generation zero is reserved and skipped.
ObjectPool[T] adds lazy creation, prewarming, and reset-on-release behavior on
top of checked leases. Its factory runs only when no reusable value is available
and the fixed capacity has not been reached.
import antono2.memory
struct Buffer {
mut:
data []u8
}
fn make_buffer() Buffer {
return Buffer{data: []u8{cap: 4096}}
}
fn reset_buffer(buffer Buffer) Buffer {
mut reset := buffer
reset.data.clear()
return reset
}
fn main() {
mut buffers := memory.new_object_pool[Buffer](16, 4, make_buffer,
reset_buffer) or { panic(err) }
handle := buffers.acquire() or { panic(err) }
mut buffer := buffers.get_mut(handle) or { panic('stale buffer handle') }
buffer.data << [u8(1), 2, 3]
released := buffers.release(handle)
assert released
// The next acquisition reuses the reset buffer.
reused := buffers.acquire() or { panic(err) }
assert (buffers.get(reused) or { panic('stale buffer handle') }).data.len == 0
}The reset callback receives the released value and returns the value to cache.
This works uniformly for structs, primitive values, and aliases. The object pool
also provides prewarm(), release_all(), counts, and a handle snapshot.
RangeAllocator manages aligned offsets inside a fixed-size resource without
owning the resource itself. It uses deterministic first fit, returns checked
allocation records, and coalesces adjacent ranges when they are released.
import antono2.memory
fn main() {
mut block := memory.new_range_allocator(256 * 1024 * 1024)
vertex_memory := block.allocate(48 * 1024, 256) or { panic(err) }
println('bind at offset ${vertex_memory.offset}')
println('exclusive end ${vertex_memory.end()}')
released := block.release(vertex_memory)
assert released
assert block.free_bytes() == block.capacity()
}The returned offset and size can index a byte buffer directly or be passed
to APIs such as vkBindBufferMemory. The allocator accepts any positive
alignment, detects alignment overflow, rejects stale/foreign/double releases,
and reports allocation count, free-range count, and largest free range.
Allocation and release are O(number of free ranges). This intentionally favors a compact, inspectable implementation; specialized strategies can be added behind separate types when benchmarks justify them.
LinearAllocator provides O(1) aligned bump allocation for data that shares a
lifetime. Individual ranges are not released; reset() invalidates all of them
at once while preserving the peak-use statistic.
import antono2.memory
fn main() {
mut frame_arena := memory.new_linear_allocator(4 * 1024 * 1024)
vertices := frame_arena.allocate(96 * 1024, 16) or { panic(err) }
uniforms := frame_arena.allocate(256, 256) or { panic(err) }
println('vertex offset ${vertices.offset}')
println('uniform offset ${uniforms.offset}')
println('alignment padding ${frame_arena.stats().padding}')
frame_arena.reset()
assert !frame_arena.contains(vertices)
}Consumed bytes include alignment gaps; payload and padding are also reported separately. Failed allocations never advance the cursor. This makes the type useful for frame uploads, parsers, request-scoped storage, and scratch buffers.
RingAllocator reuses a fixed-size range for contiguous allocations retired in
the same order they were created. It is designed for staging buffers, streaming
data, and resources whose lifetime follows a GPU submission or producer queue.
import antono2.memory
fn main() {
mut uploads := memory.new_ring_allocator(64 * 1024 * 1024)
frame_0 := uploads.allocate(4 * 1024, 256) or { panic(err) }
frame_1 := uploads.allocate(8 * 1024, 256) or { panic(err) }
// Retire allocations when their submissions complete.
frame_0_released := uploads.release(frame_0)
assert frame_0_released
frame_1_released := uploads.release(frame_1)
assert frame_1_released
}Payloads never cross the end of the managed range. If an allocation wraps, the
unused suffix is counted as padding and reclaimed with that allocation. Release
is strictly FIFO: attempting to release a newer live allocation returns
false without changing allocator state. Statistics expose payload, padding,
free space, peak occupancy, allocation count, and the largest currently usable
contiguous region.
BuddyAllocator specializes in power-of-two arenas such as GPU memory blocks,
page groups, and fixed scratch heaps. Requests are rounded up to the smallest
block satisfying the requested size, alignment, and configured minimum block
size. Allocation splits larger blocks; release recursively coalesces free
buddies.
import antono2.memory
fn main() {
mut pages := memory.new_buddy_allocator(64 * 1024 * 1024, 256) or { panic(err) }
allocation := pages.allocate(6000, 4096) or { panic(err) }
assert allocation.offset % 4096 == 0
println('payload=${allocation.size}, reserved=${allocation.block_size}')
assert pages.release(allocation)
}Capacity, minimum block size, and requested alignments must be powers of two.
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.
SynchronizedRangeAllocator and SynchronizedBuddyAllocator wrap 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.
They are available from the same antono2.memory import as the lightweight
allocators, but their explicit names keep the synchronization cost visible.
import antono2.memory
fn worker(mut arena memory.SynchronizedRangeAllocator, 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 := memory.new_synchronized_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.
antono2.vkmemalloc is a
directly usable Vulkan integration built on RangeAllocator. It suballocates
compatible buffers from memory-type-specific VkDeviceMemory blocks, keeps
images isolated for Vulkan granularity safety, honors dedicated-allocation
metadata, and provides a real device smoke example. Its UploadRing builds on
this module's RingAllocator to provide persistently mapped staging slices that
are retired in GPU submission order.
v install antono2.vkmemallocThe Vulkan module owns API-specific handles and policy while this module remains dependency-free and useful for host memory, files, parsers, resource tables, and other bounded allocation domains.
Runnable actor, retained-buffer, free-range, frame-arena, and streaming-upload examples are included:
v run examples/object_pool
v run examples/buffer_pool
v run examples/range_allocator
v run examples/linear_allocator
v run examples/ring_allocator
v run examples/buddy_allocator
v run examples/synchronized_allocatorsWhen working from a source checkout rather than an installed V module, run
./scripts/run_examples.sh; the script creates an isolated module path and does
not modify the user's installed modules.
Run the deterministic churn workloads with production compiler optimizations:
./scripts/run_benchmarks.shPass 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, 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.
v fmt -verify .
v vet .
./scripts/run_tests.sh
./scripts/run_examples.sh
./scripts/run_benchmarks.sh --quick
./scripts/run_sanitizers.sh
./scripts/run_thread_sanitizer.shThe 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 synchronized 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.
- 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
This project is available under the MIT License.