840 lines
38 KiB
Markdown
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.
|