mcap/modules/Basic/docs/fixed-arena.md

20 KiB

FixedArena

A bump allocator over one block that is allocated up front, fully committed, and used as-is. fixed_arena.jai, no platform layer.


Model

A FixedArena takes one block of memory, reserves its first bytes for a header, and hands out the rest by advancing a cursor. It never grows. When the block is full, allocation returns null — that is the normal, expected way for a fixed arena to fail, and callers are meant to check for it.

Three regions describe an arena, separated by two boundaries:

memory.data       fixed_arena_start()        memory.data + cursor      memory.data + memory.count
     |                    |                           |                            |
     +---- header --------+---- live allocations -----+------- unallocated --------+
     |  FIXED_ARENA_      |    handed out             |    never handed out        |
     |  HEADER_BYTES      |                           |                            |
Boundary Meaning
memory.data Start of the block. Null exactly when the arena is uninitialized.
fixed_arena_start() First allocatable byte. Always memory.data + FIXED_ARENA_HEADER_BYTES.
memory.data + cursor Bump position. Everything below it has been handed out.
memory.data + memory.count End of the block. Nothing is ever handed out at or past this.

The header bytes are reserved whether or not the arena is bootstrapped. For a bootstrapped arena that is where the FixedArena struct actually lives; for one set up over a caller's buffer those bytes go unused. Paying them either way buys a single address layout, so fixed_arena_start, IS_THIS_YOURS and the reset path need no special case. arena.jai makes the same trade for the same reason.

Individual allocations cannot be freed. You reclaim in bulk with fixed_arena_reset, or scope it with auto_reset.

Alignment padding comes out of the block. Each allocation begins at the next FIXED_ARENA_DEFAULT_ALIGNMENT-aligned address, and the bytes skipped to reach it are consumed like any others. So the room actually left is not memory.count - cursor — that overstates it by up to FIXED_ARENA_DEFAULT_ALIGNMENT - 1 bytes. Ask fixed_arena_available instead.

Allocations are not zeroed. Zeroing the block on creation would touch every page up front, and it would be a guarantee the first reset silently withdrew, since a reset does not re-zero. Zero what you need yourself.

Choosing between FixedArena and Arena

FixedArena Arena
Backing memory One block from an allocator, or a buffer you own A large virtual reservation
Growth None; full means null Commits pages on demand up to the reserve
Platform calls None VirtualAlloc / mmap
Alignment Fixed at FIXED_ARENA_DEFAULT_ALIGNMENT (16) Per-arena, settable, default 8
Reclaim fixed_arena_reset, auto_reset Reset, release, and the secure-erase variants
Struct size 40 bytes 32 bytes in release, larger under ARENA_DEBUG

Reach for FixedArena when you know the budget up front, when you want the arena to sit inside memory you already have (a static buffer, a slab from somewhere else), or when you cannot make platform virtual-memory calls. Reach for Arena when the ceiling is unknown and you would rather reserve big and commit lazily.

Quick start

// Bootstrapped: the arena allocates its own block and lives inside it.
arena := bootstrap_fixed_arena(64 * 1024);
if !arena  return;                  // too small, or the allocation failed
defer delete_fixed_arena(arena);    // `arena` dangles after this -- see below

{
    push_arena(arena);              // context.allocator is the arena for this scope
    auto_reset(arena);              // and the cursor rewinds when the scope ends

    data := NewArray(100, float32);
    if !data.data  return;          // a full arena returns null; check it
}
// Over memory you already own. The struct lives outside the block.
backing: [8192] u8;
arena: FixedArena;
if !fixed_arena_init(*arena, backing)  return;

p := fixed_arena_alloc(*arena, 256);
if !p  return;

Constants and types

Constants

Name Value Meaning
FIXED_ARENA_DEFAULT_ALIGNMENT 16 Every allocation is aligned to this, as an address.
FIXED_ARENA_HEADER_BYTES size_of(FixedArena) Reserved at the base of every block.
FIXED_ARENA_MINIMUM_SIZE header + alignment Smallest block either constructor accepts.

Note that FIXED_ARENA_DEFAULT_ALIGNMENT is 16 while ARENA_DEFAULT_ALIGNMENT in arena.jai is 8. The difference is deliberate but it matters: memory handed out by an Arena is only 8-aligned, so a FixedArena placed in it starts from a block whose base is not 16-aligned. The allocator aligns the address it returns rather than the offset into the block, so this works — but it is why the distinction exists.

FixedArena

FixedArena :: struct {
  memory: []u8;
  cursor: s64;
  allocator: Allocator;
}
Field Meaning
memory The block. data is null exactly when the arena is uninitialized.
cursor Offset of the bump position from memory.data. Starts at FIXED_ARENA_HEADER_BYTES.
allocator The allocator that owns memory. Read by delete_fixed_arena; null when the caller owns the block.

Do not write cursor directly. In particular cursor = 0 is not a reset: for a bootstrapped arena it lets the next allocation overwrite the struct describing the block being allocated from. Use fixed_arena_reset.

Lifecycle

bootstrap_fixed_arena

bootstrap_fixed_arena :: (size: s64) -> *FixedArena

Allocates a size-byte block from context.allocator and puts the arena's own header at the base of it, so nothing else has to own the struct.

size covers the header as well as the memory you get to hand out, so the usable capacity is at most size - FIXED_ARENA_HEADER_BYTES, and less once alignment padding between allocations is accounted for. fixed_arena_available reports the real figure at any moment.

Returns null if size is below FIXED_ARENA_MINIMUM_SIZE, or if the allocation fails. Both log. There is no partial success.

The pointer dangles after delete_fixed_arena. The struct is inside the block being released, so it cannot be nulled for you. Do not read any field afterwards — is_valid included, since it dereferences and will fault rather than report anything useful. Null your own copy, and make sure no other copy outlives the delete.

fixed_arena_init

fixed_arena_init :: (arena: *FixedArena, memory: []u8,
                     allocator := Allocator.{}) -> bool

Sets an arena up over memory the caller already owns — a static array, a slab from another arena — with the FixedArena struct outside the block rather than at its base.

allocator is the allocator that owns memory. Leave it at its default when the caller owns the memory and nothing should ever free it: delete_fixed_arena then does nothing, which is the correct answer for a static buffer.

Returns false, with an error logged, for a null arena, a null block, or a block below FIXED_ARENA_MINIMUM_SIZE. A failed init leaves the arena invalid rather than half-set-up.

Unlike the bootstrapped case, the struct survives delete_fixed_arena and can be initialized again and reused.

delete_fixed_arena

delete_fixed_arena :: (arena: *FixedArena)

Releases the block through arena.allocator — the allocator that made it, which is not necessarily context.allocator at the point of the call. Tolerates null.

Does nothing when arena.allocator.proc is null, which is the caller-owned-buffer case from fixed_arena_init.

Do not call this inside a push_arena(arena) scope. It would work, but it reads as though the arena frees itself, and what actually happens depends on which allocator is installed.

is_valid

is_valid :: (arena: *FixedArena) -> bool

True when the arena has a block large enough to hold its header and a cursor inside that block. False for null. Every allocating entry point checks this first, so you do not need to.

Not usable on a bootstrapped arena after delete_fixed_arena: it dereferences the pointer.

Allocation

fixed_arena_alloc

fixed_arena_alloc :: (arena: *FixedArena, byte_count: s64) -> *void

Bump-allocates byte_count bytes, aligned to FIXED_ARENA_DEFAULT_ALIGNMENT. Returns null rather than a pointer past the end of the block.

Condition Result
Fits Aligned pointer; cursor advances to the end of the allocation.
Does not fit null, logged. The cursor does not move, so a retry with a smaller size is not penalised.
byte_count is 0 A valid pointer. No memory is handed out; the cursor moves only by alignment padding.
byte_count is negative null, logged.
byte_count near S64_MAX null, logged. The capacity test is written so it cannot overflow.
arena is null or invalid null, logged.

A zero-byte request always succeeds, including against an exactly full arena and regardless of where the block starts, because nothing is handed out and so there is nothing to align. It is the one request fixed_arena_available does not describe. Worth knowing if you are checking alloc(0) for null as a proxy for exhaustion — it is not one, in either direction.

fixed_arena_available

fixed_arena_available :: (arena: *FixedArena) -> s64

The largest byte_count the next fixed_arena_alloc can satisfy.

This is not memory.count - cursor. The next allocation starts at the next aligned address, and the padding to reach it comes out of the block, so the raw gap overstates what will fit by up to FIXED_ARENA_DEFAULT_ALIGNMENT - 1 bytes. Asking for the raw gap is the mistake this function exists to prevent; it is what the test suite got wrong at three separate sites on its first run.

Returns 0 for a null or invalid arena and for a block with no room left, so it is always safe to compare against without a null check first.

A zero-byte request is exempt: it succeeds even when this reports 0.

// Fill the rest of the block exactly.
remaining := fixed_arena_available(arena);
p := fixed_arena_alloc(arena, remaining);   // succeeds; cursor now equals memory.count
q := fixed_arena_alloc(arena, 1);           // null

fixed_arena_start

fixed_arena_start :: (arena: *FixedArena) -> *u8

The first allocatable byte: memory.data + FIXED_ARENA_HEADER_BYTES. This is where fixed_arena_reset rewinds to, and the lower bound IS_THIS_YOURS tests against.

Resizing

fixed_arena_resize

fixed_arena_resize :: (arena: *FixedArena, old_memory: *void,
                       old_size: s64, requested_size: s64) -> *void

Resizes a block, extending or shrinking it where it sits when it is the most recent allocation — that is, when old_memory + old_size is exactly the current cursor. Anything else allocates a fresh block and copies min(old_size, requested_size) bytes.

The in-place case matters more here than it would for a growable arena. Array growth is the dominant caller of RESIZE, and without it, growing an array to N elements consumes O(N²) bytes of a block that by definition cannot grow.

Shrinking the most recent allocation reclaims the difference.

A null old_memory is legal — growing an array that has not allocated yet — and is treated as a plain allocation. Returns null on a negative size, an invalid arena, or a request that does not fit.

Resetting

fixed_arena_reset

fixed_arena_reset :: (arena: *FixedArena)

Hands the whole block back for reuse by rewinding the cursor to fixed_arena_start — not to offset zero. The header bytes stay reserved, which is what stops the next allocation from overwriting a bootstrapped arena's own struct.

Previously allocated memory is not overwritten, only forgotten. If it held anything sensitive, wipe it yourself before resetting; unlike arena.jai, there is no fixed_arena_reset_overwriting_memory.

Does nothing on a null or invalid arena.

Scoped helpers

push_arena

push_arena :: (arena: *FixedArena) #expand

Installs the arena as context.allocator for the enclosing scope and restores the previous allocator on exit. Everything that allocates through the context — New, NewArray, array_add, String_Builder — comes out of the arena.

It restores the allocator, not the cursor. A scope that allocates without an auto_reset alongside consumes from the block for the life of the program.

auto_reset

auto_reset :: (arena: *FixedArena) #expand

Records the cursor and restores it when the enclosing scope exits. Nests correctly. The usual pairing is both macros together:

{
    push_arena(arena);
    auto_reset(arena);
    ...
}

The mark holds the allocation position and nothing else, matching how Arena_State is scoped in arena.jai. Alignment and the backing allocator are configuration rather than position, and restoring them would silently undo unrelated changes a caller had made.

Allocator integration

get_allocator

get_allocator :: (arena: *FixedArena) -> Allocator

Wraps the arena as an Allocator for passing to ,, call sites, storing in a struct, or installing manually.

fixed_arena_allocator_proc

fixed_arena_allocator_proc :: (mode: Allocator_Mode, requested_size: s64, old_size: s64,
                               old_memory: *void, allocator_data: *void) -> *void

The Allocator_Proc. Every mode is handled explicitly, under #complete, so a mode added upstream is a compile error here rather than a silent null.

Mode Behaviour
.ALLOCATE fixed_arena_alloc.
.RESIZE fixed_arena_resize.
.FREE null. A bump allocator cannot release an individual allocation.
.IS_THIS_YOURS True only for memory that is both allocated by this arena and currently live — between fixed_arena_start and the cursor. The header and the unallocated tail are not yours, and a reset un-owns everything it handed out.
.CAPS IS_THIS_YOURS | HINT_I_AM_A_FAST_BUMP_ALLOCATOR, and fills in the name.
.STARTUP, .SHUTDOWN null.
.THREAD_START, .THREAD_STOP null. No per-thread state is built.
.CREATE_HEAP, .DESTROY_HEAP null.

A null allocator_data is survivable in every mode. get_capabilities in module.jai passes null outright, because capabilities are a property of the proc rather than of any one heap.

Memory debugger

Memory_Debugger deliberately ignores this allocator, the same way it ignores the pool and temporary allocators. Allocations from a FixedArena are not tracked, and an overrun here would not be caught for you — the only allocation the debugger sees is the block bootstrap_fixed_arena takes from context.allocator.

That is why every bound this allocator cares about is checked inside fixed_arena.jai rather than left to tooling, and why tests/fixed_arena_tests.jai is the only regression net there is.

Thread safety

None. Nothing in this file is thread-safe.

Allocation is an unsynchronized read-modify-write of cursor, so two threads allocating from one FixedArena will hand out overlapping memory. .CAPS correctly omits Allocator_Caps.MULTIPLE_THREADS, and THREAD_START / THREAD_STOP are accepted as no-ops rather than being used to build per-thread state.

The dangerous part is that nothing stops you. push_arena installs the arena as context.allocator, and a context is exactly the sort of thing that gets copied to a worker.

Safe patterns:

  • One arena per thread. The usual answer.
  • Hand off ownership. One thread fills an arena and passes it to another, provided there is a real happens-before edge and only one thread touches it at a time.
  • Read-only sharing. Multiple threads reading memory already allocated, as long as none of them allocates or resets.

Unsafe, including cases that look benign:

  • Two threads calling fixed_arena_alloc on one arena, however briefly.
  • One thread allocating while another calls fixed_arena_reset or exits an auto_reset scope — the rewind can hand out live memory again.
  • Sharing a context that carries a fixed arena allocator.

A lock around the allocating calls alone is not enough. It has to cover everything that touches the cursor: fixed_arena_alloc, fixed_arena_resize, fixed_arena_reset, auto_reset's scope exit, and the allocator proc.

Correctness notes and limitations

  • A full arena returns null, it does not abort. This is the normal failure mode and callers are expected to handle it. Nothing in the file asserts on exhaustion.
  • Allocations are not zeroed, and a reset does not zero either.
  • There is no secure-erase path. arena.jai has arena_wipe and the *_overwriting_memory variants; this file has no equivalent. Wipe sensitive memory yourself before resetting.
  • cursor is an offset, not a pointer, and the header bytes are reserved in both layouts. Do not persist a cursor value across a change of build configuration — size_of(FixedArena) is what sets the start, and a struct layout change moves it.
  • The bootstrapped pointer dangles after delete. Covered above; it is the single easiest way to misuse this file.
  • No alignment control. Alignment is fixed at FIXED_ARENA_DEFAULT_ALIGNMENT. There is no equivalent of arena_set_alignment or push_alignment. If you need a larger alignment for a particular allocation, over-allocate and align by hand.
  • In-place resize only helps the most recent allocation. Resizing anything buried under a later allocation copies, and the original is stranded until the next reset.

Testing

tests/fixed_arena_tests.jai covers every exported symbol across seven groups, each behind a RUN_*_TESTS constant so groups can be enabled individually. The module-scope state machinery (Fixed_Arena_State and its pair) is exercised through auto_reset.

Each test names the review finding it guards, so a failure points at the reasoning as well as the line. The reasoning lives in fixed-arena-code-review.html.

Two conventions worth knowing before reading the output:

  • Tests that drive failure paths deliberately log. Those are bracketed by (expecting logged errors: ...) banners — noise inside a banner is the test working, noise outside one is not.
  • The suite reports per group rather than as one total. A check is one expect call reached at runtime, not one distinct property, so a check inside a loop counts once per iteration.

The suite passes: 165 checks over seven groups. Its first run failed 8 of 152 checks, all of them the same defect and all of them in the tests: three sites computed the room left as memory.count - cursor and so asked for more than could fit. The allocator was right to refuse every time. What the failures actually exposed was a missing piece of API — there was no way for a caller to ask how much would fit — which is why fixed_arena_available exists and why the raw-gap calculation is called out twice above. With that closed, and with a zero-byte request made independent of the block's base address, the suite runs clean.

The cases most worth keeping if the file is ever trimmed are the boundary ones: allocating to exact capacity and one byte past it, a zero-byte request against a full arena, and the alignment group's deliberately 8-but-not-16-aligned block. Those are the three places where the obvious implementation is wrong in a way that ordinary use does not reveal.