38 KiB
Arena
A reserve-and-commit bump allocator. arena.jai holds the portable logic, arena_platform.jai
the Windows and POSIX virtual-memory calls.
- Model
- Quick start
- Constants and types
- Lifecycle
- Allocation
- Alignment
- Resetting and releasing
- Scoped helpers
- Allocator integration
- Secure erase and page locking
- Introspection
- Platform layer
- Thread safety
- Correctness notes and limitations
- Planned features
- 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:
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:
a := bootstrap_arena(.Size_2M, label = "frame");
if !a return;
defer arena_delete(a); // `a` dangles after this -- see Lifecycle
Scoped reclamation:
{
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.
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.
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. |
Secure_Arena |
Lock each range into physical memory as it is committed. A hint in the same sense. |
Chained |
#Planned, no effect. |
Arena
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
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
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
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
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
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
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
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:
{
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
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.
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
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
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
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
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
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
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. |
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.
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_allocon one arena, however briefly. - One thread allocating while another calls
arena_reset,arena_release, or anyauto_*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:
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:
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 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_bytesandarena_committed_bytesmust sum across the chain.IS_THIS_YOURSmust walk it, and stops being O(1).Arena_Statemarks become a (block, point) pair rather than one pointer, andrestore_arena_statemust free every block allocated after the mark.arena_releaseandfree_pages_down_toneed 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_TESTSis off by default. Those cases violate preconditions that are enforced byassert, so with asserts on they abort the process rather than failing a check. Enable them only in a build withENABLE_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.