473 lines
20 KiB
Markdown
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.
|