> blog // memory

A 1 TiB ceiling that costs 3 MiB

By Neper · · Measured with Neper's own compiler on Windows and Linux; every number is from a run, and the limits are listed

A Neper program used to stop at 1 GiB unless it was built with a flag, and the compiler was a Neper program like any other. A million-line build failed with an arena error, though it needs only 605 MB of RAM. Now a program takes what it needs, up to a 1 TiB ceiling, on Windows, Linux and macOS, and gives it back. That costs nothing, because the size of an arena was never a cost. It is a ceiling on addresses, and memory arrives when a page is first written.

1 TiBthe default ceiling, with no flag
3 MiBpeak RAM of a program at start (Windows)
605 MBpeak RAM to compile 920,554 lines
132 MiBleft in RAM after a 2 GiB spike and a reset
The same story in 3 minutes 41 seconds, with the real commands and numbers. Captioned, with a synthetic voice. Watch on YouTube.

The error

The workload is sc1m, the generated million-line program from A million lines, eight compilers: 1,000 modules, 82,595 functions, 920,554 lines. That post had to build the compiler with --arena 4g first. This is why, with a release compiler built with the old 1 GiB ceiling:

$ neper-1g emit-executable src/main.e REPO x64 windows sc1m.exe --release --unchecked --cpu x64-v3 -j 8
error[E-TYPE-9999]: resource limit: the compiler's arena is exhausted; the program is larger than this compiler was built to hold

And this is the same command with the same compiler source, built with the new default:

$ neper emit-executable src/main.e REPO x64 windows sc1m.exe --release --unchecked --cpu x64-v3 -j 8
executable written
$ ./sc1m.exe
108474335

It took 3.6 seconds and the answer is the one the generator expected. The peak working set was 605 MB. The old build did not run out of memory. It ran out of a number.

One arena, in the open

Neper has no garbage collector and no hidden heap. A program's memory is an arena, a block with a base, a capacity and an offset, and main receives it as its first parameter. A function that allocates takes the arena as an argument, so you can see from a signature whether it can allocate at all.

fn main(a: *mem.Arena, args: []str) -> err {
    let (block, block_error) = mem.alloc[u8](a, 4096usize)
    if block_error != ok { ret block_error }
    ...
}

mem.alloc is a bump and a bounds compare. mem.mark and mem.reset free a whole phase at once. Running out is an ordinary error value, mem.Exhausted, and the program decides what to do with it.

Because an arena hands out raw pointers, it cannot be moved to a bigger block when it fills up. Nothing moves under a pointer in Neper. So an arena that can grow needs one unbroken range of addresses, set aside before the first allocation.

Reserved is not allocated

That range is what the startup code asks for, and asking for addresses is nearly free. The operating system records the range and hands over no memory. Real pages arrive the first time the program writes to them.

Windows

The arena is reserved, not committed. The first write to an uncommitted page faults, and a handler the runtime installs commits the 1 MiB chunk around it and resumes the instruction.

Linux

The arena is an anonymous mapping with MAP_NORESERVE. The kernel backs each page on first touch, so a ceiling larger than the machine is not refused.

Both of those were already true. What changed is the default, from 1 GiB to 1 TiB, in one place in the compiler that every hosted linker reads, and what the startup does if the host says no, which comes below. --arena still sets the size either way.

A program that grows

This program allocates six blocks of 256 MiB and writes one byte to every page, so all of it becomes real. There is no flag anywhere. The whole source is 39 lines; this is its loop:

while grown < BLOCKS {
    let (block, block_error) = mem.alloc[u8](a, BLOCK)
    if block_error != ok { ret block_error }
    var at = 0usize
    while at < BLOCK {
        block[at] = 1u8
        at += PAGE
    }
    grown += 1usize
    try report(a, "grew")
}
$ neper emit-executable grow.e . x64 windows grow.exe --release
$ ./grow.exe
start: ceiling 1048576 MiB, used 0 MiB, peak RAM 3 MiB
grew: ceiling 1048576 MiB, used 256 MiB, peak RAM 259 MiB
grew: ceiling 1048576 MiB, used 512 MiB, peak RAM 515 MiB
grew: ceiling 1048576 MiB, used 768 MiB, peak RAM 771 MiB
grew: ceiling 1048576 MiB, used 1024 MiB, peak RAM 1027 MiB
grew: ceiling 1048576 MiB, used 1280 MiB, peak RAM 1283 MiB
grew: ceiling 1048576 MiB, used 1536 MiB, peak RAM 1539 MiB
first byte is still 42

The program starts at 3 MiB under a 1 TiB ceiling, and RAM follows what it uses. It walks straight past the old 1 GiB limit. Its first allocation is still where it was, with what it held. Linux prints the same lines with slightly lower peaks: 1 MiB at the start and 1536 MiB at the end.

Allocating is not touching

A page costs nothing until it is written, so a large table that is mostly empty stays cheap. This one asks for 16 GiB, more than the machine has, and writes to 1,000 places:

let (table, table_error) = mem.alloc[u8](a, SIZE)
if table_error != ok { ret table_error }
var n = 0usize
while n < WRITES {
    table[n * (SIZE / WRITES)] = 1u8
    n += 1usize
}
$ ./sparse.exe
allocated 16384 MiB, wrote 1000 places, peak RAM 7 MiB

Linux reports 3 MiB. There is a catch on Windows. RAM is 7 MiB, but the commit charge, what counts against RAM plus pagefile, peaked at 1,006 MB. Each scattered write committed a whole 1 MiB chunk. On Windows, then, a program that touches memory sparsely pays for it in commit, though not in RAM.

Giving memory back

A long-running loop should not climb toward a ceiling at all. mem.mark notes the offset and mem.reset returns to it, so a frame's scratch memory is the next frame's scratch memory. This one allocates 1 MiB per frame for ten thousand frames:

let start = mem.mark(a)
var frame = 0usize
while frame < FRAMES {
    try draw(a)
    mem.reset(a, start)
    frame += 1usize
}
$ ./frames.exe
allocated 10000 MiB in total, used now 0 MiB, peak RAM 4 MiB

That is 10 GB of allocations in 4 MiB of RAM, with a peak commit of 1 MB. Linux reports a peak of 1 MiB.

Reusing memory is half of the job. A server that handles one huge request should not keep that memory for the rest of its life, and an arena used to do exactly that: reset moved an offset and nothing else. Now a reset that frees 512 MiB or more hands back all but the first 128 MiB of what it freed, with nothing asked for and no background thread:

$ ./spike.exe
after the spike: 2051 MiB in RAM
after mem.reset: 132 MiB in RAM

Linux prints 2048 and 129. A reset under the threshold costs one compare and makes no system call, so the frame loop above never sees any of this.

This is safe because of a rule that was already there. Using a slice after its region is reset does not compile, so no checked program can see the pages go:

$ neper check-file main.e . x64 windows
main.e:31:8: error[E-SAFETY-0013]: `small` points into a region that was reset at line 30, so it cannot be used here

Our first thresholds were wrong, and the measurement said so. At 128 MiB, a program that spiked 200 MiB fifty times went from 37 ms to 1,432 ms, because it was refaulting the same pages every round. At 512 MiB that case is back to normal, and the cost has moved somewhere much less common:

Windows, one byte written per pageBeforeAfter
RAM after a 2 GiB spike and a reset2,051 MiB132 MiB
2,000 rounds of 4 MiB7 ms12 ms
50 rounds of 200 MiB37 ms48 ms
10 rounds of 600 MiB23 ms1,026 ms

The last row is the real price: a program that repeatedly spikes past 512 MiB pays about 100 ms a round in page faults. A program that actually fills 600 MiB spends about that long writing it, so it is a slowdown of around double, not forty times, but it is a slowdown. On Linux those rows are 2,048 and 129 MiB, 9 and 8 ms, 42 and 54 ms, 24 and 1,395 ms.

Freeing one object

An arena frees everything after a mark, and nothing alone. Most of a compiler fits that shape. A server's sessions and caches do not, because their lifetimes overlap. That is what e.mem.heap is for: a heap you create and pass, so a function that takes no heap still allocates from none.

let (h, open_error) = heap.open(8589934592usize)
if open_error != ok { ret open_error }

let (session, session_error) = heap.alloc(h, 256usize)
if session_error != ok { ret session_error }
let (request, request_error) = heap.alloc(h, 4096usize)
if request_error != ok {
    heap.free(session)
    ret request_error
}

// The older block goes first, which a stack of lifetimes cannot do.
heap.free(session)
let working = heap.bytes(&request)
working[0usize] = 9u8

The heap's 8 GiB is only addresses until it is written. Sizes up to 4 KiB come from a Slab and larger ones from a Buddy, and a freed block of 1 MiB or more goes back to the operating system. In our test, 2 GiB in sizes from a byte to a mebibyte went through 64 live slots, freed in no stack order, in about 50 ms.

The part we like most costs nothing when the program runs. A Block is a resource owed to free, so these three are compile errors:

# used after it was freed
main.e:11:28: error[E-SAFETY-0001]: `block` was moved or never owned (line 10), so it cannot be used here
# freed twice
main.e:11:15: error[E-SAFETY-0001]: `block` was moved or never owned (line 10), so it cannot be used here
# never freed
main.e:10:5: error[E-SAFETY-0002]: `block` acquired at line 8 is still owned at this exit: close it, defer its close, or move it out

It caught us. Our first version of the sample above used try on a print while a block was live, and the compiler refused it, because that try could return and leak the block. It was right.

The compiler

The compiler is where this design earns its keep. It sizes its tables from the program, generously, before it knows how much of each it will need, and every phase's worker gets its own arena. Tables that are never filled are never touched, so the margin costs addresses and not memory.

Release compiler, release build1 GiB ceiling1 TiB ceilingPeak RAMPeak commit
Itself, 109,734 lines, 8 workers0.81 s0.82 s339 MB694 MB
Itself, 1 worker1.95 s1.97 s169 MBnot measured
sc1m, 920,554 lines, 8 workersE-TYPE-99993.6 s605 MB940 MB
sc1m, 1 workerE-TYPE-999916 s504 MBnot measured

For the compiler building itself, the two ceilings tie: the memory is the same, and 0.02 s is noise. The sc1m row is the point. The workers' arenas reached 4,423 MB of addresses between them, and the process touched 605 MB. The old failure was an arena's offset reaching 1 GiB, not a shortage of RAM.

What happens when those tables do get touched shows how much of that margin is untouched. A debug-built compiler fills every allocation with a pattern, so every page is real:

The compiler building itself, 8 workersTimePeak RAMPeak commit
Release-built compiler, release output0.75 s339 MB694 MB
Debug-built compiler, release output1.2 s3,425 MB3,440 MB
Build your compiler with --release.

The fill catches bugs, and a tenfold memory bill is what it costs. If a build of yours feels heavy, check which compiler you are running first.

Why a ceiling at all

A ceiling is what remains because nothing can move. The alternative is to chain regions: when an arena fills, reserve another and carry on. That has no ceiling, but it changes the 24-byte arena layout every runtime reads, leaves unused space at each boundary, and makes mark and reset span regions. On a 64-bit host, a bigger range gives the same behavior.

So the only question is how big, and that is where we went wrong first. We raised the default to 64 GiB and the Linux suite refused it. The suite runs one fixture under valgrind, and a 64 GiB arena would not start there, with the same program running natively:

Arena size1, 16, 24, 32, 40, 48, 56 GiB64 GiB
Under valgrind 3.22runsexit 111, before main

We dropped the default to 32 GiB, which fixed the suite and left a limit nobody wanted: a default build stopped at 32 GiB on a machine with more RAM. And 32 GiB was the wrong lesson. The limit was never the machine's. Valgrind loads itself at a fixed address, vStart = 0x1002000000, about 64 GiB in, so a program under it has that much on any hardware. The same kernel maps 1 TiB without complaint when valgrind is not there.

When the host says no

The real bug was that a refused reservation was fatal. Picking a smaller number only moves that cliff, so the startup no longer has one. If the host refuses the size, it halves the size and asks again, down to 1 MiB, and the arena's capacity is what was granted. This is the x64 Linux entry; Windows and both macOS entries do the same:

.Lreserve:
    ...                  # mmap(0, rbp, ...)
    syscall
    test rax, rax
    jns  .Lmapped
    shr  rbp, 1          # refused: ask for half
    cmp  rbp, 0x100000
    jae  .Lreserve       # down to 1 MiB

With that, nothing needs the default to be low, so it is 1 TiB. Every environment that used to stop a program now just gives it less. This prints the ceiling the program ended up with:

$ ./cap
ceiling 1048576 MiB
$ valgrind -q ./cap                  # was: exit 111 above 56 GiB
ceiling 32768 MiB
$ (ulimit -v 8000000; ./cap)         # was: exit 111, silently
ceiling 4096 MiB

The test for this is a program built to ask for a pebibyte, --arena 1048576g, which no 47-bit address space can hold. Before this change it exited 111. It now starts and allocates on Windows, on Linux, and on macOS. A program can also ask for far more than the old limits without a flag. This one takes 40 GiB in a single allocation, on Windows and Linux alike, and writes one page of it:

$ ./big
ceiling 1048576 MiB: 40960 MiB allocated

The compiler gets the same treatment. Each worker reserves about the root's size, so -j 8 now asks for about 9 TiB of addresses, and a worker's reservation is halved the same way, since a root that took a whole small address space would leave none for its workers.

None of this costs anything we can measure. Compiling sc1m peaks at the same 605 MB as it did at 32 GiB. A small program starts and exits in 5.4 to 5.7 ms at 1 TiB and 5.4 to 5.5 ms at 1 GiB, 41 runs each, measured twice.

When memory really runs out

A 1 TiB ceiling has a hidden cost. mem.Exhausted now almost never fires, because the ceiling is far above any machine, so running out of real memory would end in the operating system's OOM killer. A page fault has no way to return an error.

So a program that cares can ask first. pages.ensure answers now, while the program can still do something about it, and it allocates nothing:

if pages.ensure(a, 512usize * GIB) == mem.Exhausted {
    try io.printf["512 GiB is not: shed the load, and keep running\n"]()
}
$ ./ensure.exe
1 GiB is there
512 GiB is not: shed the load, and keep running

How strong that answer is depends on the host. On Windows, ensure commits the bytes, a promise from the system that no later touch can fail. On Linux it checks available memory and the cgroup's limit, then backs the pages now. On macOS, which compresses and swaps rather than refuse, it can only answer against the machine's total memory, and then writes the pages.

On Windows there is one more step, for programs that did not ask. Each page is committed by a fault handler, and when the system refused, that handler used to report fatal: access violation, which told a program with no bug that it had a wild pointer. A refusal is often only for a moment, while the pagefile grows or another process exits. So the handler now asks again for two seconds, a trick we took from Firefox. If the memory really is not there, it says so:

fatal: out of memory: the system refused to commit at 0x000000014ff11087

The exit status is 137, as Linux's OOM killer leaves it. We test this by running the program in a job limited to 256 MiB of commit, where ensure answers mem.Exhausted first, and writing on regardless ends this way after 2.3 seconds.

One more Windows edge went the same way. A kernel call cannot fault a page in, so a buffer it writes must already be committed, and this failed only in release builds, because a debug build's fill commits everything. When an @import gets &slice[i] through a writable pointer, the compiler now touches the slice first. A ReadFile into a fresh 8 MiB slice used to fail and now works. Read-only arguments cost nothing. A raw pointer from anywhere else is still the program's to touch.

How it compares

This is how the same questions are answered elsewhere. The other columns come from how those systems are documented, not from anything we measured.

NeperGoRustC++Node.js
Who manages memoryarenas and explicit heaps, passed as argumentsgarbage collectorownership, over mallocRAII, over mallocmoving garbage collector
Default limit1 TiB, or what the host givesnonenonenonea few GB, set by a flag
Do objects move?neverheap no, stacks yesVec growth, checked by the compilerthe same, not checkedyes, handled by the engine
Freeing one objecte.mem.heap, or a whole phase by resetautomaticautomaticautomaticautomatic
Memory back to the OSon a large reset or free, no background workyes, in the backgroundallocator's choiceallocator's choiceyes, after collection
Asking before running outpages.ensurenotry_reserveexception, or the OOM killerno
Use after freecompile error for heap blocks and arena regionsnot possiblecompile errorundefined behaviornot possible
Forgetting to free a blockcompile errorcollectedfreed on dropfreed by RAII, else undetectedcollected
Pauses for memory managementnoneshortnonenoneshort

Rust is the closest. Its allocator is general-purpose where ours is a choice you make per lifetime, and its borrow checker covers far more than blocks. A collector is still less work to use: Go and Node free memory with nothing to think about, and that is a real advantage for code where a little extra memory and a short pause do not matter.

What it costs

A runaway program is no longer stopped early. It keeps committing until the operating system pushes back, as a C or Go program does. If you want a hard stop, it is one flag, and the error is a value with a name:

$ neper emit-executable grow.e . x64 windows grow.exe --release --arena 512m
$ ./grow.exe
start: ceiling 512 MiB, used 0 MiB, peak RAM 3 MiB
grew: ceiling 512 MiB, used 256 MiB, peak RAM 259 MiB
error: e.mem.Exhausted

Three smaller costs. A program can no longer assume it got the size it asked for, even with --arena, so one that needs a minimum reads mem.stats(a).capacity at the start. Picking an arena or a heap is a decision you make, per lifetime, that a garbage collector makes for you. And the repeated-spike cost above is real for programs in that range.

Try it

$ neper emit-executable grow.e . x64 windows grow.exe --release
$ neper emit-executable grow.e . x64 linux grow --release --arena 2g   # a hard cap
$ neper emit-executable src/main.e . x64 windows neper.exe --release   # the compiler, the same way
$ ./spike.exe                                                     # the memory going back

All of the programs are in docs/blog/code/compiler-memory. Add --stats to a compile to see compiler peak working set and worker arenas reached side by side.

Honest limits of these numbers

Sources

Everything here was measured on October 28 and 30, 2026. If a number here has drifted, the commands above will show it.

Related posts

A million lines, eight compilers · Neper writes WebAssembly itself