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

473 lines
20 KiB
Markdown

# 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](#model)
- [Choosing between FixedArena and Arena](#choosing-between-fixedarena-and-arena)
- [Quick start](#quick-start)
- [Constants and types](#constants-and-types)
- [Lifecycle](#lifecycle)
- [Allocation](#allocation)
- [Resizing](#resizing)
- [Resetting](#resetting)
- [Scoped helpers](#scoped-helpers)
- [Allocator integration](#allocator-integration)
- [Thread safety](#thread-safety)
- [Correctness notes and limitations](#correctness-notes-and-limitations)
- [Testing](#testing)
---
## 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
```jai
// 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
}
```
```jai
// 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`
```jai
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`
```jai
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`
```jai
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`
```jai
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`
```jai
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`
```jai
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`
```jai
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.
```jai
// 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`
```jai
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`
```jai
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`
```jai
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`
```jai
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`
```jai
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:
```jai
{
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`
```jai
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`
```jai
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.