mcap/modules/Default_Allocator/DIFFERENCES.md
Musa Mahmood 22cad98500 [0.1.15] Startup: a catalog cache, the catalog on the background threads, rpmalloc, and [startup] timings
The catalog is kept in .catalog-cache in the library, each entry with the size and modified time of
the file it was read from; a start takes the entries whose file is unchanged from it and reads only
the rest. It is saved once the catalog is complete and has changed (at most every 30 s) and at
shutdown. After a reboot the catalog was complete 145 ms after launch, against 3.5 s reading every
file.

The catalog loads on the background Thread_Group, every worker of it, instead of the four preview
workers. rpmalloc (modules/Default_Allocator, 2.0.1-jai.16) takes the place of Jai's single locked
heap: entries from the cache 380 -> 50 ms, and reading the files now scales with threads.
thread_idle is called where workers idle. check_targets.jai checks other OSes with Jai's own
allocator, as rpmalloc runs at compile time with the target's OS layer.

-cold drops the library from the system's file cache first; -no-catalog-cache and -catalog-threads N
are for measuring. [startup] log lines time the listing, the first frame, the cache, the first
preview and the complete catalog.
2026-10-13 07:26:42 +01:00

14 KiB
Raw Permalink Blame History

How this Default_Allocator differs from Jai's

This module replaces Jai's modules/Default_Allocator with the rpmalloc 2.0.1 port from my_modules/rpmalloc (installed with its install.jai). Jai finds the default allocator by module name, so the replacement is the directory that comes first on the import path. This document lists everything that behaves differently. Facts about Jai's module refer to Jai beta 0.2.030.

Everything here is tested by the port's suite, my_modules/rpmalloc/tests:

  • jai build.jai - swap installs this module with install.jai, builds a program whose only allocator is the installed copy, runs that program, and type-checks it for macOS and Linux.
  • The port's own suite (89 tests) covers the allocator code itself.

1. Which allocator you get

Platform Jai's Default_Allocator This module
Windows x64 Stripped-down rpmalloc 1.4.4: one heap shared by every thread behind one lock, and no thread caches rpmalloc 2.0.1 (port), one heap per thread (tested)
Linux x64 The same as on Windows x64 The same as on Windows (type-checked only; there is no Linux machine)
macOS ARM64 A thin wrapper over the C runtime's malloc / realloc / free rpmalloc 2.0.1 (port) (tested)
macOS x64, Linux ARM64 Linux ARM64: C runtime wrapper; macOS x64: stripped-down rpmalloc 1.4.4 Builds, but untested
PS5, Switch 2, iOS, Android, WASM, 32-bit Their own branches (rpmalloc 1.4.4 on PS5 and Switch 2; the C runtime wrapper elsewhere) Not supported. The module stops with an #assert. Take my_modules off the import path for these targets.

The CAPS string tells you which allocator is in use: "rpmalloc 2.0.1 (Jai port) as Default_Allocator". Jai's reports "stripped-down rpmalloc 1.4.4 intended as Default_Allocator", or "Jai wrapper for C’s malloc/free/realloc functions.".

2. Exported interface

Name Jai's This module
allocator Yes (rpmalloc branch only) Yes
allocator_proc Yes Yes, same signature
unsafe_reset() rpmalloc branch: forgets all state and starts over. C runtime branch: does nothing. Releases every lock and keeps all state (§6)
initialize_if_needed() -> bool Yes Yes
is_initialized() -> bool Yes Yes
finalize() Yes Yes (the port's finalize)
usable_size(ptr) -> s64 Yes Yes
is_this_yours(heap, ptr) -> bool Walks the heap's span lists. It misses older large blocks (finding F4/M2, test HEAP-05) and the blocks of exited threads (HEAP-10). With a null heap it looks only in the one default heap. Looks the address up in a bitmap of live spans and never reads memory it does not own. With a null heap it checks every thread heap (not first-class heaps).
heap_free_all(heap) Yes Yes
global_statistics() A placeholder struct of 7 fields, always zero rpmalloc's Global_Statistics (9 fields). It is zero unless ENABLE_STATISTICS. The memory committed now is active; committed and decommitted are running totals that only grow, as upstream. The pointer form global_statistics(*stats) also exists.
heap_statistics(heap) {sizecache, spancache} {allocated_size, committed_size, mapped_size}, zero unless HEAP_STATISTICS
allocator_default_heap A global *Heap Not provided. There is no single default heap; each thread has its own.
The rest of the rpmalloc API No initialize, thread_collect, trim, rpmalloc, rpaligned_alloc, heap_acquire, and the rest (see my_modules/rpmalloc/module.jai)

Module parameters. ENABLE_ASSERTS and ENABLE_VALIDATE_ARGS have the same names and defaults (both true). The port's other parameters are added: statistics, decommit, unmap, first-class heaps, the huge cache and test hooks. Runtime_Support imports the module without parameters, so the context allocator always uses the defaults. Importing it with other parameters creates a separate instance with its own state; the context allocator is not affected.

3. Allocator protocol

Mode Jai's This module
ALLOCATE / RESIZE / FREE with data == null rpmalloc branch: the one default heap, behind a global lock. C runtime branch: malloc. The calling thread's own heap, with no lock on the fast path. A block freed by another thread is handed back to its owning heap without blocking.
Out of memory Asserts with asserts on (N1, tests SIZE-01 and SIZE-07) Returns null
Negative or impossibly large size Not caught (F11, SIZE-02 and SIZE-03). The C runtime wrapper panics in debug builds (N5). Returns null; a refused RESIZE leaves the block intact
RESIZE old_size Ignored When the block moves, old_size bytes are copied; 0 means the whole old block. Passing a positive old_size smaller than the live data loses the rest.
RESIZE to the same size Can move the block (I1, RE-02) Keeps it
A refused RESIZE Returns the old pointer (L6, RE-04) Returns null
STARTUP Does nothing; returns null initialize(); returns null. Process/posix.jai stores this return value in context.allocator.data, where null means "the thread's heap".
SHUTDOWN Forgets the default heap; later use fails (F3, LIFE-01) finalize(); the next allocation initialises again
THREAD_START / THREAD_STOP Do nothing Set up / release the thread's heap. Both are optional: a thread gets its heap on its first allocation, and the heap is released when the thread exits. Jai's Thread module sends these only on Windows (N4).
CREATE_HEAP rpmalloc branch: yes. C runtime branch: asserts. Yes (with FIRST_CLASS_HEAPS, the default)
DESTROY_HEAP Releases the heap; a new heap can then claim the destroyed heap's blocks (F5, HEAP-01). Blocks still in it leak: 1,970 MiB over 190 heaps (HEAP-11). Frees every block in the heap, then releases it
IS_THIS_YOURS rpmalloc branch: see §2. C runtime branch: asserts. See §2
CAPS flags rpmalloc branch: general heap, threads, CREATE_HEAP, FREE, ACTUALLY_RESIZE, IS_THIS_YOURS. C runtime branch: no heaps, no IS_THIS_YOURS. The same as the rpmalloc branch, on every platform

First-class heaps. A heap from CREATE_HEAP is for one thread at a time, as in upstream rpmalloc. Putting it in a Context that several threads copy lets them race on it.

4. Threads

  • Heap per thread. Each thread allocates from its own heap, kept in OS thread-local storage (TlsAlloc/FlsAlloc, or a pthread key). A Context copied to another thread does not share the heap.
  • Heaps of exited threads are reused. A thread's heap is released when the thread exits, even without THREAD_STOP, and the next new thread takes it over. With Jai's module on Windows and Linux, all threads share one heap behind one lock: there is no per-thread state, but every allocation contends for that lock.
  • Freeing another thread's block. A block freed by a thread other than its owner is queued without a lock. Its owner reuses it on that owner's next allocation, or on thread_collect(). If the owner has exited, or is idle in thread_idle(), the block is freed at once instead.
  • Thread pools: call thread_idle(). A pool thread that sleeps makes no allocator calls, so blocks other threads free into its heap, and the heap's free pages, would stay committed until it wakes. Calling Default_Allocator.thread_idle() where a pool thread waits for work gives that memory back and parks the heap until the thread's next allocation. Jai's module has no per-thread state, so it needs no such call.
  • Partly used pages per thread. Each heap keeps its own partly used pages, so a program with many threads holds some more memory in partly filled small pages (64 KiB each) than one shared heap would. Larger pages are committed as they fill.

5. Memory use

  • Retention. The port keeps freed pages per thread and huge blocks in a cache of up to 128 MiB committed (HUGE_CACHE_LIMIT_MB; a program that frees and allocates many huge blocks may run faster with more, holding up to that much while it does). Cached entries idle for a second are decommitted; blocks beyond the budget are unmapped. It decommits free pages only after a delay. Jai's rpmalloc branch returns memory eagerly, and pays page faults for it on reuse (F1, RET-01: about 112 faults per warm cycle). trim() returns every free page of the calling thread's heap, plus the whole huge cache, at once. thread_collect() does the same for pages past their delay.

  • Address space. Memory is reserved in 256 MiB spans and committed page by page. Virtual size therefore looks large; the committed size is what costs memory. A 128 KiB bitmap for IS_THIS_YOURS is mapped once.

  • Measured footprint (MiB with the benchmark's live set allocated; my_modules/rpmalloc/tests/results/BENCH-*.md):

    Benchmark Windows: Jai's Windows: this macOS: Jai's (C runtime) macOS: this
    churn_medium 204 170 148 66
    churn_large 157 260 128 7.8
    churn_huge 296 452 28.5 4.2
  • macOS. The port decommits with MADV_FREE_REUSABLE, so freed memory leaves the process's footprint.

6. fork()

Process/posix.jai calls unsafe_reset() in the child, then allocator_proc(.STARTUP). Jai's rpmalloc branch forgets its whole state at that point, so every block allocated before the fork leaks in the child. This module instead releases each lock that another thread of the parent might have held: the global heap lock, the heap-creation flag, the huge-cache lock, and an initialisation another thread had started. Everything else stays as it was. The forking thread's heap and its blocks remain valid in the child. Like Jai's, it cannot repair a list another thread was in the middle of changing; that window is a few instructions under one of those locks. jai build.jai - swap checks this: without unsafe_reset the smoke test hangs, and with it the test passes.

7. Compile time

Code in #run allocates through this module as well; the swap smoke test checks this. Jai's module also works at compile time.

8. Build

  • This module imports Basic, Bit_Operations and Atomics; Jai's imports nothing.
  • Its code is about 4,000 lines; Jai's rpmalloc branch is about 2,000.
  • Unsupported platforms fail to compile (§1).
  • One Context field. The module adds rpmalloc_default_allocator_thread_heap (a pointer) to every program's Context, a cache of the thread's heap, so allocations and frees need no OS thread-local storage call. A second import of the module with other parameters must pass CONTEXT_HEAP_CACHE = false, since two imports cannot add the same field. Jai's module adds nothing to the Context.
  • It works in programs built with arithmetic_overflow_check = .FATAL: no code relies on unsigned wraparound. In my_modules/rpmalloc/tests, jai build.jai - swap overflow=fatal and the port's suite with overflow=fatal test this. Both pass on Windows and macOS; Linux is type-checked only.

9. Speed

Median ns per operation, module defaults, from my_modules/rpmalloc/tests/results/BENCH-*.md; lower is better.

Benchmark Windows: Jai's Windows: this macOS: Jai's (C runtime) macOS: this
pair_64 (alloc + free of 64 B) 34.3 13.1 9.7 12.2
pair_mixed (16..1024 B) 256 15.5 23.6 13.4
churn_medium (1..64 KiB) 1059 51.7 74.0 53.2
churn_large (64 KiB..1 MiB) 4600 32.3 122 30.9
churn_huge (2..32 MiB) 9526 20245 618 4751
realloc_double (64 B to 8 MiB) 184662 15492 6373 6601
mt_churn_8 (8 threads) 515 2.3 18.0 1.9
mt_producer (cross-thread frees) 484 46.4 122 87.3

On Windows this module is faster everywhere except churn_huge (replacing blocks of 2 MiB and up), where it is 2× slower. On macOS, where Jai's module is the system malloc, it is level or faster except on pair_64 (0.8×) and churn_huge (7× slower). That gap is shelved in the plan.

10. Test results

The test suite was run against Jai's module and against the port:

Platform Jai's Default_Allocator This module's code (the port)
Windows x64 (Jai's: rpmalloc 1.4.4) 30 pass, 11 fail, 4 crash (41 skipped: capabilities it lacks) 82 pass; every other test is SKIP or N/A
macOS ARM64 (Jai's: C runtime malloc) 30 pass, 6 fail, 2 crash (48 skipped) 83 pass; every other test is SKIP or N/A

What Jai's module fails, by kind:

  • Correctness, Windows:
    • SIZE-01 and SIZE-07 assert when the OS refuses memory.
    • SIZE-02 and SIZE-03 accept negative sizes.
    • RE-02: a same-size resize moves the block.
    • RE-04: a refused resize returns the old pointer.
    • HEAP-01: a new heap claims a destroyed heap's blocks.
    • HEAP-05 and HEAP-10: IS_THIS_YOURS misses blocks it owns.
    • HEAP-11: destroyed heaps leak their blocks.
    • LIFE-01: the allocator is unusable after SHUTDOWN.
  • Correctness, macOS: SIZE-02 crashes on a negative size, and RE-04 is the same as on Windows.
  • Memory behaviour, Windows: RET-01, RET-06, RET-10 and RET-14 fail on page faults. Freed memory goes back to the OS and faults in again on every reuse (up to 2,053 faults per 8 MiB cycle).
  • Memory behaviour, macOS: OS-07, RET-02, RET-07, RET-11 and RET-12 fail because the system malloc keeps freed memory private (258 of 258 MiB after freeing everything). THR-09: blocks freed by another thread are not reused first.

One upstream bug remains open in the port: U15 (POSIX only; at most one heap per exiting thread can leak, in a rare destructor order). See my_modules/rpmalloc/module.jai.

11. Before you switch a project

  • Check every RESIZE / realloc call for a correct old_size (§3). Jai's module ignores it; this one relies on it.
  • Expect a larger footprint on Windows for large and huge blocks, or call trim() at quiet points.
  • Workloads with many huge blocks (2 MiB and up) are slower than with Jai's module (§9).
  • Supported platforms are Windows and macOS; Linux is type-checked only.
  • A block must still be freed by the allocator that made it. Each program and DLL compiled with this module has its own instance, just as with Jai's.