mcap/modules/Basic/docs/Arena.md

840 lines
38 KiB
Markdown

# Arena
A reserve-and-commit bump allocator. `arena.jai` holds the portable logic, `arena_platform.jai`
the Windows and POSIX virtual-memory calls.
- [Model](#model)
- [Quick start](#quick-start)
- [Constants and types](#constants-and-types)
- [Lifecycle](#lifecycle)
- [Allocation](#allocation)
- [Alignment](#alignment)
- [Resetting and releasing](#resetting-and-releasing)
- [Scoped helpers](#scoped-helpers)
- [Allocator integration](#allocator-integration)
- [Secure erase and page locking](#secure-erase-and-page-locking)
- [Introspection](#introspection)
- [Platform layer](#platform-layer)
- [Thread safety](#thread-safety)
- [Correctness notes and limitations](#correctness-notes-and-limitations)
- [Planned features](#planned-features)
- [Testing](#testing)
---
## Model
An arena takes one large reservation of address space up front, commits physical pages behind
it on demand, and hands out memory by advancing a pointer. Individual allocations are never
freed; you reclaim in bulk by rewinding to the start or to a saved mark.
Four boundaries describe an arena, three of them stored in the struct:
```
memory_base arena_start() current_point first_uncommitted_page arena_address_limit()
| | | | |
+---- Arena --------+---- live --------+---- committed ----+------ reserved --------+
| header | allocations | slack | address space only |
```
| Boundary | Meaning |
|---|---|
| `memory_base` | Start of the reservation. Null exactly when the arena is uninitialized or its reservation failed. |
| `arena_start()` | First allocatable byte. Always `memory_base + size_of(Arena)`, rounded up to `ARENA_DEFAULT_ALIGNMENT`. |
| `current_point` | Bump pointer. Everything below it has been handed out. |
| `first_uncommitted_page` | End of physically backed memory. Always page-aligned. |
| `arena_address_limit()` | End of the reservation. |
The invariant chain is
`memory_base <= arena_start() <= current_point <= first_uncommitted_page <= arena_address_limit()`.
Reserving is cheap — it consumes address space, not memory — so picking a generous reserve class
costs little on a 64-bit target. Committing is what has a real cost, and only happens as
allocations demand it.
---
## Quick start
An arena you own, with the struct on the stack or in your own data:
```jai
a: Arena;
success := arena_init(*a, .Size_64M);
if !success return;
defer arena_delete(*a);
buffer := arena_alloc(*a, 4096);
{
push_arena(*a); // context.allocator for this scope
nodes := NewArray(1024, Node);
}
```
An arena that owns itself, with the header stored in its own first page:
```jai
a := bootstrap_arena(.Size_2M, label = "frame");
if !a return;
defer arena_delete(a); // `a` dangles after this -- see Lifecycle
```
Scoped reclamation:
```jai
{
auto_reset(*a); // rewind to here on scope exit, keep the pages
scratch := arena_alloc(*a, 1024 * 1024);
...
}
```
---
## Constants and types
### Module parameter
`ARENA_DEBUG` (default `false`) adds `high_water_mark`, `label`, `file_path`, `function_name`
and `line_number` to `Arena`, and makes `update_high_water_mark` do work. It grows
`size_of(Arena)`, which moves `arena_start()`. See
[Correctness notes](#correctness-notes-and-limitations).
### Constants
| Name | Value | Notes |
|---|---|---|
| `ARENA_DEFAULT_COMMIT_PAGE_COUNT` | `16` | Counted in the arena's own pages: 64 KB normally, 32 MB on a 2 MB-page arena. |
| `ARENA_DEFAULT_ALIGNMENT` | `8` | Also the alignment `arena_start` rounds to. |
| `PLATFORM_MEMORY_PAGE_SIZE` | `4096` | The default granularity. A `.Large_Pages` arena uses `platform_large_page_size()` instead; read the arena's own size with `arena_page_size()`. |
| `PLATFORM_MEMORY_PAGE_SIZE_LOG2` | `12` | The `page_size_log2` an arena starts at. |
| `PLATFORM_CACHE_LINE_SIZE` | `64` | Declared, unused. |
| `Arena_Sizes` | `[7] s64` | The reserve table, indexed by `Arena_Reserve`. |
`#run assert(Arena_Sizes.count == type_info(Arena_Reserve).values.count)` keeps the table and
the enum from drifting; adding a class to one and not the other fails the build.
### `Arena_Reserve`
`enum u8`, one member per reserve class: `Size_64K`, `Size_2M`, `Size_64M`, `Size_2G`,
`Size_64G`, `Size_2T`, `Size_64T`. The value is an index into `Arena_Sizes`.
The three largest classes are 64-bit only in practice, and can fail on a Linux host configured
with `overcommit_memory=2` — see [Platform layer](#platform-layer).
### `Arena_Flags`
`enum_flags u8`. `Is_Bootstrapped`, `Large_Pages` and `Secure_Arena` are live and are passed to
`arena_init` or `bootstrap_arena`. `Chained` is declared so the bit layout is settled before
anything persists an `Arena`, but setting it has no effect; it is marked `#Planned` in the
source.
| Flag | Effect |
|---|---|
| `Is_Bootstrapped` | Set by `arena_set_bootstrap_flag`; the `Arena` lives in its own first page. |
| `Large_Pages` | Back the reservation with the platform's large/huge pages. A hint: see [Large pages](#large-pages). |
| `Secure_Arena` | Lock each range into physical memory as it is committed. A hint in the same sense. |
| `Chained` | `#Planned`, no effect. |
### `Arena`
```jai
Arena :: struct {
current_point: *u8;
memory_base: *u8;
first_uncommitted_page: *u8;
reserve: Arena_Reserve; // enum u8
flags: Arena_Flags; // enum_flags u8
status: Arena_Status; // enum_flags u8
alignment_log2: u8;
page_size_log2: u8 = PLATFORM_MEMORY_PAGE_SIZE_LOG2;
initial_commit_page_count_log2: u8;
}
```
32 bytes when `ARENA_DEBUG` is off. Keeping it small is deliberate: the header is carved out of
the front of every arena's own reservation, so each byte added comes out of usable space. Three
pointers and six single-byte fields is 30 bytes, rounding to 32 at pointer alignment, so two
bytes are spare and one field wider than a byte would push it to 40. That is what the log2s
buy — alignment, page size and the initial commit count each fit in a byte. The test suite
asserts the size for this reason: if it fires, that is a decision to make rather than a number
to update.
**Read the three counts through their accessors**, never off the field:
| Accessor | Returns |
|---|---|
| `arena_alignment(arena)` | The alignment every allocation is rounded up to. |
| `arena_page_size(arena)` | This arena's page granularity. |
| `arena_initial_commit_pages(arena)` | The initial commit, in this arena's pages, and the floor `arena_release` decommits down to. |
Because they are log2s, all three are necessarily powers of two — which is why `arena_init`
rounds a requested `commit_page_count` up to one, and why `arena_set_alignment` rejects
non-power-of-two alignments rather than rounding them.
### `Arena_Status`
`enum_flags u8`, recording what actually happened to the arena as opposed to what the caller
asked for. Both bits are sticky and never cleared.
| Flag | Meaning |
|---|---|
| `Secure_Lock_Failed` | A `.Secure_Arena` page lock was refused. A range that spent time unlocked cannot be made retroactively safe by a later success. |
| `Large_Pages_Failed` | A `.Large_Pages` request fell back to `PLATFORM_MEMORY_PAGE_SIZE`. Pages already handed out at 4 KB granularity do not become large ones later. |
It is a separate field from `Arena_Flags` rather than two of that enum's four free bits, and not
for size reasons — both layouts land at 32 bytes. `arena_init` merges caller-supplied flags into
`flags`, so an outcome living there would be settable by a caller, which is not an outcome.
Read the bits through `arena_secure_lock_intact` and `arena_large_pages_active`, which also
check that the corresponding capability was requested at all.
---
## Lifecycle
### `arena_init`
```jai
arena_init :: (arena: *Arena, new_reserve: Arena_Reserve,
commit_page_count := ARENA_DEFAULT_COMMIT_PAGE_COUNT,
new_flags: Arena_Flags = .None)
-> (success: bool, pages_committed: s64 = 0)
```
`new_flags` is merged into `arena.flags` before the reservation happens, so `.Large_Pages`
reaches the reserve call and `.Secure_Arena` covers the first commit as well as every later one.
Reserves address space, commits the first `commit_page_count` pages, and establishes every
invariant above. `pages_committed` reports what was actually committed, which is not always
what you asked for:
| Request | Result |
|---|---|
| Negative | Clamped to `ARENA_DEFAULT_COMMIT_PAGE_COUNT`, warned. |
| Zero | Clamped to `ARENA_DEFAULT_COMMIT_PAGE_COUNT`, warned. |
| Not a power of two | Rounded **up** to one, silently. |
| Larger than the reserve | Clamped to the reserve, warned. |
| Commit fails | `pages_committed` is 0; `success` is still `true`. |
The rounding is what lets the count be stored in a byte as a log2. Up rather than down, so the
initial commit is never smaller than asked for; every other outcome in the table already lands
on a power of two, since `ARENA_DEFAULT_COMMIT_PAGE_COUNT` is one and a reserve class divided
by a page size is one. `arena_commit_first_pages` is *not* rounded — a direct caller passing
`start_offset_bytes` can still produce a non-power-of-two commit, and that path does not write
the field.
`success` reports only that the *reservation* worked. An arena can come back with
`success == true` and `pages_committed == 0`, which is usable — allocation will commit on
demand — but is not enough for a bootstrapped arena, whose header needs a page. Check both if
you care.
Calling it on an arena that already has a `memory_base` is a no-op returning
`(true, arena_initial_commit_pages(arena))`. It will not re-reserve, and it will not change the reserve
class. `null` returns `(false, 0)` with an error logged.
### `bootstrap_arena`
```jai
bootstrap_arena :: (new_reserve: Arena_Reserve,
default_commit_page_count: s32 = ARENA_DEFAULT_COMMIT_PAGE_COUNT,
label := "", loc := #caller_location) -> *Arena
```
Creates an arena whose `Arena` struct lives in its own first page, so nothing else has to own
it. Returns `null` if the reservation fails, or if it succeeds with no pages behind it.
`label` and `loc` are recorded only under `ARENA_DEBUG`; the strings are copied with
`context.default_allocator` and freed by `arena_delete`.
**The pointer dangles after `arena_delete`.** The struct is inside the region being unmapped,
so it cannot be nulled for you. Do not call `is_valid` or `arena_details` on it afterwards —
both dereference and will fault rather than report anything. Null your own copy, and make sure
no other copy outlives the delete.
### `arena_delete`
```jai
arena_delete :: (arena: *Arena)
```
Releases the whole reservation (`MEM_RELEASE` / `munmap`) and, under `ARENA_DEBUG`, frees the
recorded strings. A no-op if `!is_valid(arena)`. Failures are logged and the procedure returns
without clearing `memory_base`, so the struct never claims to have released something still
mapped.
For a **non-bootstrapped** arena it clears `memory_base`, making `is_valid` false and leaving
the struct ready to be initialized again. For a **bootstrapped** arena it cannot, per above.
### `is_valid`
```jai
is_valid :: (arena: *Arena) -> bool
```
`arena != null && arena.memory_base != null`. Safe on a null pointer; **not** safe on a
bootstrapped arena that has been deleted.
---
## Allocation
### `arena_alloc`
```jai
arena_alloc :: (arena: *Arena, byte_count: s64) -> *void
```
Aligns `current_point` up to `arena_alignment(arena)`, reserves `byte_count` bytes, commits any pages
needed, and advances. Returns `null` on every failure, without moving `current_point`:
| Condition | Behaviour |
|---|---|
| `arena` is null | Logs, returns `null`. |
| `arena.memory_base` is null | Logs, self-initializes with a **`.Size_64G`** reserve, then proceeds. |
| `byte_count < 0` | Logs, returns `null`. |
| Request exceeds the reservation | Logs, returns `null`. |
| Commit fails | Returns `null`. |
`byte_count == 0` is legal and returns the current aligned point.
The self-initializing path is a safety net, not a feature. It logs at error level and picks a
64 GB reserve because address space is cheap and a wrong guess here is hard to recover from;
initialize your arenas properly.
The bounds test is written as a subtraction against the limit
(`byte_count > arena_address_limit(arena) - result`) so it cannot overflow, which a
`result + byte_count` form could.
---
## Alignment
Every allocation is aligned to `arena_alignment(arena)`, which starts at `ARENA_DEFAULT_ALIGNMENT`
(8). The Jai allocator interface carries no per-allocation alignment, so this is the only
control available.
### `arena_set_alignment`
```jai
arena_set_alignment :: inline (arena: *Arena, alignment: s64)
```
Must be a power of two no wider than the reservation. Non-positive or non-power-of-two values
are asserted in debug, logged and ignored in release. Values above `reserve_size_bytes(arena)`
are clamped to it with an error, because an alignment wider than the whole reservation makes
every allocation fail the address-limit check.
The reservation is the only bound. This used to clamp at 32768 because the field was a `u16`
and anything larger truncated to zero; the field is a log2 now, so a 2 MB alignment on a large
enough arena is exact.
This is the single place the field is written; everything else routes through it.
### `push_alignment`
```jai
push_alignment :: (arena: *Arena, alignment: s64) #expand
```
Sets the alignment for the enclosing scope and restores the previous value on exit via a
`defer`. Nests correctly. An invalid alignment is rejected before anything is changed.
Use this for types the default 8 does not satisfy — anything holding `__m128`/`__m256`, or a
value the compiler assumes is naturally aligned:
```jai
{
push_alignment(*a, 32);
vectors := NewArray(count, Vector8);
}
```
---
## Resetting and releasing
All four are no-ops on an invalid arena.
| Procedure | Rewinds | Decommits |
|---|---|---|
| `arena_reset(arena)` | yes | no |
| `arena_reset_keeping_memory(arena)` | yes | no |
| `arena_release(arena)` | yes | down to `arena_initial_commit_pages(arena)` |
| `arena_reset_overwriting_memory(arena, $wipe_function)` | yes, after wiping | no |
`arena_reset` and `arena_reset_keeping_memory` are currently identical; the latter is a
compatibility spelling.
Reset is the cheap one: the pages stay committed, so the next round of allocation does no
system calls. Release hands pages back to the OS and is worth it only when the arena has grown
far past its steady state and will stay there.
`arena_reset_overwriting_memory` zeroes everything between `arena_start()` and `current_point`
before rewinding. `$wipe_function` defaults to `memory_secure_overwrite`; passing `null`
skips the wipe.
Rewinding does not invalidate pointers in any enforced sense — the memory stays mapped and
readable — but every pointer into the rewound region is logically dead and will be handed out
again by the next allocation.
---
## Scoped helpers
```jai
auto_reset (arena: *Arena) #expand
auto_release (arena: *Arena) #expand
auto_reset_overwriting_memory (arena: *Arena, $wipe_function := memory_secure_overwrite) #expand
auto_release_overwriting_memory(arena: *Arena, $wipe_function := memory_secure_overwrite) #expand
```
Each captures `current_point` and installs a `defer` restoring it at the end of the enclosing
scope. The `release` variants also decommit back down to `arena_initial_commit_pages(arena)`; the
`overwriting` variants wipe the region between the mark and the current point first.
```jai
process :: (a: *Arena, input: [] u8) -> Result {
auto_reset(a); // everything below is scratch
table := arena_alloc(a, 1 << 20);
...
return copy_out(result); // copy anything that must outlive the scope
}
```
They nest, and unwind in reverse order. The underlying `Arena_State`, `get_arena_state` and
`restore_arena_state` are `#scope_module` deliberately: a mark and its restore must refer to
the same arena, and keeping the pair internal means an outside caller cannot pair them wrongly.
### `push_arena`
```jai
push_arena :: (arena: *Arena) #expand
```
Installs the arena as `context.allocator` for the enclosing scope and restores the previous
allocator on exit. Combines with `auto_reset` to get both a scoped allocator and scoped
reclamation.
---
## Allocator integration
### `get_allocator`
```jai
get_allocator :: (arena: *Arena) -> Allocator
```
Returns `.{arena_allocator_proc, arena}` for passing to anything that takes an `Allocator`.
### `arena_allocator_proc`
The standard `Allocator_Proc`. Behaviour by mode:
| Mode | Behaviour |
|---|---|
| `ALLOCATE` | `arena_alloc`. Returns `null` on failure. |
| `RESIZE` | Always allocates fresh and copies `min(old_size, requested_size)` bytes. Never resizes in place. |
| `FREE` | Ignored, returns `null`. |
| `IS_THIS_YOURS` | True only for `arena_start() <= p < current_point`, i.e. currently live. |
| `CAPS` | `IS_THIS_YOURS \| HINT_I_AM_A_FAST_BUMP_ALLOCATOR`, and fills the name string. |
| `STARTUP`, `SHUTDOWN`, `THREAD_START`, `THREAD_STOP`, `CREATE_HEAP`, `DESTROY_HEAP` | Accepted, return `null`. |
Every mode tolerates a null `allocator_data`. This matters: `Basic.get_capabilities` passes
`null` deliberately, since capabilities describe the proc rather than any one heap.
`IS_THIS_YOURS` answers about the *live* range, so a pointer into committed-but-unallocated
slack is disowned, and so is everything after a reset.
The advertised capabilities are deliberately narrow. `FREE` is absent because `.FREE` does
nothing; `ACTUALLY_RESIZE` is absent because resize always reallocates; `MULTIPLE_THREADS` is
absent because nothing here is synchronized.
---
## Secure erase and page locking
### `arena_wipe`
```jai
arena_wipe :: (start: *u8, end: *u8, $wipe_function := memory_secure_overwrite) #expand
```
Zeroes the half-open interval `[start, end)`. **Returns without doing anything if
`end <= start`.** Every wipe in the module routes through this, which is what keeps a negative
length away from `memory_secure_overwrite` — on x64 that is a `rep stosb` with the count in
`rcx`, so a negative `s64` would become a wipe of roughly the whole address space.
`memory_secure_overwrite` now lives in the `Secure_Memory` module rather than in Basic, and
that module's `USE_SODIUM` defaults to true, so the wipe goes through libsodium's
`sodium_memzero` and the assembly above is the fallback for a build that opts out.
`#if wipe_function` means passing `null` compiles the call away entirely.
### `arena_secure_overwrite`
```jai
arena_secure_overwrite :: (arena: *Arena, $endpoint := Arena_Secure_Overwrite_Endpoint.Current_Point)
```
Zeroes from `arena_start()` to either `current_point` (everything handed out) or
`first_uncommitted_page` (that plus the committed slack). `$endpoint` is a constant, resolved
with `#if`.
Neither moves `current_point`. To wipe *and* rewind, use `arena_reset_overwriting_memory`.
### `arena_secure_overwrite_starting_from`
```jai
arena_secure_overwrite_starting_from :: (arena: *Arena, start: *u8)
```
Zeroes from `start` to `first_uncommitted_page`. An out-of-range `start` is a no-op via
`arena_wipe`'s guard rather than an underflow.
### `arena_lock_pages` / `arena_unlock_pages`
```jai
arena_lock_pages :: (arena: *Arena)
arena_unlock_pages :: (arena: *Arena)
```
`VirtualLock` / `mlock` over the committed region, keeping it out of the page file. Failures
are logged, not asserted — these legitimately fail against a small working-set quota or
`RLIMIT_MEMLOCK`, so treat success as best-effort and check your logs if it matters.
Locking covers `memory_base` through `first_uncommitted_page`. Pages committed *after* a lock
are not covered; lock again if the arena has grown.
---
## Introspection
| Procedure | Returns |
|---|---|
| `arena_start(arena)` | First allocatable address. |
| `arena_address_limit(arena)` | End of the reservation. |
| `arena_usage_bytes(arena)` | `current_point - arena_start()`. |
| `arena_committed_bytes(arena)` | `first_uncommitted_page - memory_base`. Includes the header. |
| `reserve_size_bytes(arena)` / `(Arena_Reserve)` | Size of the reserve class. |
| `arena_is_bootstrapped(arena)` | Whether the header lives inside the arena. |
| `arena_details(arena)` | Human-readable summary. Safe on `null`. |
| `next_reserve(size, alignment)` | Smallest class that can hold `size` bytes of user data. |
Note the asymmetry: `arena_usage_bytes` measures from `arena_start()` and so excludes the
header, while `arena_committed_bytes` measures from `memory_base` and includes it. Usage will
never equal committed even on a completely full arena.
`next_reserve` accounts for the header and alignment padding, so `next_reserve(64 * 1024, 8)`
returns `.Size_2M` — a 64 KB class cannot hold 64 KB of user data once the header is carved
out. It saturates at `.Size_64T`.
`arena_details` returns a string allocated from temporary storage. It is a debugging aid; do
not hold on to the result.
---
## Platform layer
Exported from `arena_platform.jai`. Most callers never need these — `arena_init` and
`arena_alloc` drive them — but they are public and the test suite exercises them directly.
| Procedure | Behaviour |
|---|---|
| `arena_platform_init(arena, reserve) -> bool` | Reserves address space, and sets `page_size_log2` and `first_uncommitted_page` alongside `memory_base`. **Postcondition: `memory_base` is null if and only if this returned false.** |
| `extend_committed_pages(arena, end) -> bool` | Commits through `end`, rounded up to one of the arena's pages. Returns `true` without doing anything if `end` is already committed. |
| `platform_large_page_size() -> s64` | The machine's large page size, or `0` where unavailable. Cached. See [Large pages](#large-pages). |
| `arena_commit_first_pages(arena, commit_size, start_offset_bytes = 0) -> bytes_committed` | Commits the initial run. Clamps as described under `arena_init`. Returns the true page-aligned figure. |
| `Align(n, alignment) -> n` | Rounds up. Asserts the alignment is a positive power of two. Works on integers and pointers. |
`free_pages_down_to` is `#scope_module`. It decommits everything above a page count without
consulting `current_point`, which is safe only because its two callers (`arena_release` and
`restore_arena_state`) rewind first. Keeping it internal is what makes that safe rather than
merely conventional.
### Platform differences worth knowing
**Decommit is not symmetric.** Windows uses `MEM_DECOMMIT`, so reading a stale pointer into a
decommitted page faults. POSIX uses `madvise(MADV_DONTNEED)`, which leaves the pages mapped and
reading back as zeros. A use-after-release bug is therefore loud on Windows and silent on
Linux and macOS. Test that class of bug on Windows.
**POSIX commits eagerly.** The `mmap` in `arena_platform_init` maps the whole reservation
`PROT_READ | PROT_WRITE`, so `extend_committed_pages` there is bookkeeping only and cannot
fail. No `MAP_NORESERVE` is passed, so on a host configured with `overcommit_memory=2` the
large reserve classes will fail at reservation time.
**A granted large-page reservation is committed eagerly everywhere.** On Windows and macOS it
is wired at reserve time and cannot be partially given back, which makes both
`extend_committed_pages` and `free_pages_down_to` pure bookkeeping for such an arena. Linux
`MAP_HUGETLB` keeps the normal decommit behaviour, at huge-page granularity. See
[Large pages](#large-pages).
**Failure is reported, not asserted.** Every `VirtualAlloc`, `VirtualFree`, `mmap` and `munmap`
result is checked and logged, and the failure propagates to the caller as `false` or `null`.
Nothing in the commit path relies on an assert that a release build would strip.
---
## Thread safety
**None. This module is not thread-safe in any respect.**
There is no lock, no atomic, and no per-thread state anywhere in either file. `arena_alloc`
performs an unsynchronized read-modify-write on `current_point`, so two threads allocating
from one arena 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 an arena as
`context.allocator`, and a context is exactly the sort of thing that gets copied to a worker
thread. Every allocation on that thread then races.
Safe patterns:
- **One arena per thread.** The usual answer. Arenas are cheap; give each worker its own.
- **Hand off ownership.** One thread may fill an arena and pass it to another, provided there
is a real happens-before edge between them and only one thread touches it at a time.
- **Read-only sharing.** Multiple threads may read memory already allocated, as long as none
of them allocates, resets, or releases.
Unsafe, including cases that look benign:
- Two threads calling `arena_alloc` on one arena, however briefly.
- One thread allocating while another calls `arena_reset`, `arena_release`, or any `auto_*`
scope exit — the rewind can hand out live memory again.
- Sharing a context that carries an arena allocator.
- One thread reading arena memory while another resets or releases, which on Windows can fault
and on POSIX will silently read zeros.
If you need a shared allocator, wrap it yourself, and note that a lock around `arena_alloc`
alone is not enough — the reset and release paths need to be inside the same lock.
---
## Correctness notes and limitations
Known, accepted, or deferred. None of these are bugs pending a fix unless stated.
**Alignment tops out at the reservation.** Requests above `reserve_size_bytes(arena)` are
clamped with an error. This was a 32768 ceiling imposed by a `u16` field, which would have made
2 MB alignment inexpressible; the field is `alignment_log2: u8` now, so the constraint is the
meaningful one rather than a field width.
**The default alignment of 8 under-aligns some types.** The allocator interface has no
per-allocation alignment, so anything needing 16 or 32 bytes must be wrapped in
`push_alignment`. This differs from the default heap allocator's guarantee.
**Every arena pays for the header.** `arena_start()` skips `size_of(Arena)` whether or not the
struct is stored there. For a non-bootstrapped arena those bytes are simply never written.
Accepted: it keeps one address layout for both kinds, and the cost is 32 bytes against a
reservation of megabytes at minimum.
Branching on `arena_is_bootstrapped` to reclaim them is not rejected on cost — the test would be
a byte load and a `CMOV` off a cache line the caller already has. It is rejected because
`arena_start` has to stay a pure function of `memory_base`: callers compare two of its results
across an arena's life, and `flags` is mutable. It is also not set yet where it would be needed,
since `arena_init` runs before the arena is known to be bootstrapped at all.
**`arena_start()` moves between build configurations.** `ARENA_DEBUG` adds five fields, so
`size_of(Arena)` and therefore the first allocatable address differ between debug and release.
Never persist an offset into arena memory across a configuration change.
**`.RESIZE` does not check `old_memory`.** A caller passing a null `old_memory` with a non-zero
`old_size` will make the `memcpy` read from address zero. Callers are expected to pass a real
block, but the guard is one condition and is worth adding.
**Reset does not invalidate anything.** Memory below `current_point` stays mapped after a
rewind. Stale pointers read plausible data right up until the next allocation reuses the
bytes. The `overwriting` variants exist for when that matters.
**`Chained` does nothing.** It is declared to fix the bit layout; setting it has no effect, and
it is marked `#Planned` in the source. `Large_Pages` and `Secure_Arena` are live.
**`Large_Pages` and `Secure_Arena` are hints, not guarantees.** Both can be refused by the OS,
and a refusal produces a working arena with the capability missing rather than a failed
`arena_init`. Ask `arena_large_pages_active` or `arena_secure_lock_intact` if the answer
matters; a caller that never asks cannot tell the difference.
**A large-page arena does not return memory.** On Windows and macOS the reservation is
committed and wired in one call and cannot be partially decommitted, so `arena_release` and the
`free_pages` path of `restore_arena_state` rewind `current_point` and nothing more.
**`start_offset_bytes` has no in-tree caller.** `arena_commit_first_pages` implements and
guards it, and the test suite covers it, but no production code exercises it. Treat it as
provisional until `Arena_Array` exists.
**`arena_alloc`'s self-initializing path picks 64 GB.** Reaching it means an uninitialized
arena escaped into allocation. It logs at error level. Do not rely on it.
**Large reserve classes are not universally available.** `Size_2T` and `Size_64T` need a
64-bit address space, and on Linux with strict overcommit even `Size_64G` can fail. The failure
is clean — `arena_init` returns `false` — but plan for it if you use the top of the table.
---
## Large pages
*`Arena_Flags.Large_Pages`*
Backing an arena with the platform's large/huge pages — 2 MB in practice — instead of 4 KB, to
cut TLB pressure on large working sets. Pass the flag to `arena_init` or `bootstrap_arena`:
```jai
arena := bootstrap_arena(.Size_64M, flags = .Large_Pages, label = "big working set");
if !arena_large_pages_active(arena) {
// Still a perfectly good 4 KB arena. Only worth a log if the TLB win was the point.
}
```
| Procedure | Meaning |
|---|---|
| `platform_large_page_size() -> s64` | The machine's large page size, or `0` where unavailable. Cached after the first call. |
| `arena_is_large_pages(arena) -> bool` | The flag was requested. Says nothing about whether it was granted. |
| `arena_large_pages_active(arena) -> bool` | Requested **and** granted. This is the one to test. |
| `arena_page_size(arena) -> s64` | This arena's granularity: `platform_large_page_size()` when active, `PLATFORM_MEMORY_PAGE_SIZE` otherwise. |
The granularity is per-arena, held as a `page_size_log2: u8`, so 4 KB and large-page arenas
coexist in one process and a refusal downgrades one arena rather than invalidating a build-wide
assumption. Every page calculation in both files runs off `arena_page_size` / `page_size_log2`;
`PLATFORM_MEMORY_PAGE_SIZE` is now only the default.
**Requesting it is a hint.** A request is refused when the machine reports no large page size,
when the reservation cannot hold a whole number of large pages (`Size_64K`), or when the OS
declines the call. Any of those logs a warning, sets the sticky `Arena_Status.Large_Pages_Failed`,
and continues with a working 4 KB arena. There is deliberately **no ceiling on the reserve class** —
a caller asking for large pages on a large reservation gets what it asked for.
**Windows.** One `VirtualAlloc` with `MEM_RESERVE | MEM_COMMIT | MEM_LARGE_PAGES`, because
`MEM_LARGE_PAGES` has no reserve-only form. The whole reservation is therefore committed and
wired immediately, `first_uncommitted_page` starts at the top, and `free_pages_down_to` returns
early — a large-page allocation cannot be partially decommitted. It also needs
`SeLockMemoryPrivilege`, which is not granted by default; acquiring it is the main program's
job (`windows_enable_large_pages_if_possible` in the app's platform layer), and without it this
module simply falls back.
**Linux.** `MAP_HUGETLB` first, sized against `Hugepagesize:` from `/proc/meminfo`. It draws
from a hugetlbfs pool an administrator preallocated with `vm.nr_hugepages`, so on a default box
it fails — and then the plain mapping is retried with `madvise(MADV_HUGEPAGE)` behind it.
Transparent huge pages are advisory, so that path reports `arena_large_pages_active` as false
and keeps 4 KB arithmetic, but costs one syscall and may recover most of the win. `MAP_HUGETLB`
pages *can* be dropped with `madvise`, at huge-page granularity, so decommit still works there.
**macOS.** `VM_FLAGS_SUPERPAGE_SIZE_2MB` passed in `mmap`'s `fd` argument, x86_64 only —
Apple Silicon exposes no user-facing superpage API, so `platform_large_page_size` reports `0`
there. Superpages are wired at map time like Windows large pages, with the same consequence for
`free_pages_down_to`.
**Interaction with `.Secure_Arena`.** Large pages are non-pageable by construction, so an arena
with both flags locks nothing: `arena_manages_page_locks` is false, the per-commit
`VirtualLock`/`mlock` is skipped, and `arena_lock_pages` / `arena_unlock_pages` report success
without a syscall. The residency guarantee holds either way, and skipping it avoids both a
syscall on the allocation path and a spurious `Arena_Status.Secure_Lock_Failed`.
**Allocation alignment is a separate axis.** Page granularity and `arena_set_alignment` are
unrelated — large pages need no oversized allocation alignment. A 2 MB *allocation* alignment
used to be inexpressible because `Arena.alignment` was a `u16`; it is `alignment_log2: u8` now,
bounded only by the reservation, so both work independently.
---
## Secure arenas
*`Arena_Flags.Secure_Arena`*
Locking an arena's pages into physical memory so its contents cannot reach the page file or a
hibernation image. Pass the flag the same way:
```jai
arena := bootstrap_arena(.Size_64M, flags = .Secure_Arena, label = "vault");
```
| Procedure | Meaning |
|---|---|
| `arena_is_secure(arena) -> bool` | The flag was requested. |
| `arena_secure_lock_intact(arena) -> bool` | Requested, and every lock the arena took succeeded. |
| `arena_lock_pages(arena) -> bool` | One-shot lock of everything committed. Rarely needed directly. |
| `arena_unlock_pages(arena) -> bool` | The inverse; called at teardown. |
Each range is locked as it is **committed**, not once at init, because a lock taken at init
covers only the initial commit and leaves everything the arena later grows into unlocked. The
decommit path unlocks first, both to keep the process's locked-page accounting honest on
Windows and because `madvise(MADV_DONTNEED)` would otherwise refuse a locked range on Linux.
A failed lock is recorded in the sticky `Arena_Status.Secure_Lock_Failed` and never propagated: a low
`RLIMIT_MEMLOCK` or working-set quota must not turn into an out-of-memory. The quota is the
caller's to arrange before creating the arena — see `os_request_memory_lock_quota` in the app's
platform layer.
See [Large pages](#large-pages) for why the two flags together lock nothing.
**Still open.** Wiping on every reset and release rather than only through the `overwriting`
variants; wiping before `arena_delete` hands the reservation back; and whether `push_arena` on
a secure arena should be loud or refused, since routing general-purpose allocation into it
defeats the purpose.
---
## Planned features
One feature has a hook in the source but no behaviour: `Arena_Flags.Chained` is declared so the
bit layout is settled before anything persists an `Arena`, and is marked `#Planned` where it is
declared. Setting it today does nothing.
What follows is what it would take, recorded now so the reasoning does not have to be
rediscovered later.
### Expandable (chained) arenas
*`Arena_Flags.Chained` · `#TODO: Consider how to approach ExpandableArenas`*
An arena that links a fresh block when the current one runs out, instead of refusing the
allocation.
The obstacle is that **one reservation is a load-bearing assumption**, not an implementation
detail. Because there is exactly one block that never moves, `arena_address_limit()` is a
single address, `arena_usage_bytes` is one subtraction, `IS_THIS_YOURS` is one range test in
constant time, and every pointer stays valid for the arena's whole life. Chaining costs all
four:
- `arena_address_limit()` stops having a single meaningful value.
- `arena_usage_bytes` and `arena_committed_bytes` must sum across the chain.
- `IS_THIS_YOURS` must walk it, and stops being O(1).
- `Arena_State` marks become a (block, point) pair rather than one pointer, and
`restore_arena_state` must free every block allocated after the mark.
- `arena_release` and `free_pages_down_to` need a per-block policy: keep the first block's
initial commit and drop the rest, presumably.
- Every block carries a header, so the per-arena overhead becomes per-block.
**Decide first:** whether a chained arena is the same type or a separate one. Making `Arena`
chainable puts a branch in every accessor and taxes the common non-chained path; a distinct
type keeps the simple case simple and duplicates the API surface.
**Consider whether it is needed.** A reservation is address space, not memory, so on a 64-bit
target raising the reserve class is usually the cheaper answer to "this might need to grow" —
`Size_64G` costs nothing until it is committed. Chaining earns its keep when address space
itself is scarce, or when many arenas must each stay small.
---
## Testing
`tests/arena_tests.jai` covers every exported symbol of both files across ten groups, each
behind a `RUN_*_TESTS` constant so groups can be enabled individually. The module-scope
machinery is exercised through its public wrappers: `free_pages_down_to` via `arena_release`,
and the `Arena_State` pair via the `auto_*` macros.
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.
- `RUN_ASSERT_TRIP_TESTS` is off by default. Those cases violate preconditions that are
enforced by `assert`, so with asserts on they abort the process rather than failing a check.
Enable them only in a build with `ENABLE_ASSERT = false`, to confirm the release-mode
fallbacks log and return rather than proceeding.
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
breakdown makes it visible where coverage actually sits.