Region-based GC for Julia: worst event pause 4 ms → 55 µs

I run discrete-event simulations that drive hardware in the loop, which means
every event has a deadline of about 100 µs. Julia’s collector is fine on
throughput but occasionally it stops the world for milliseconds, and one collection in the wrong place is a missed deadline. The usual fix is pooling everything by hand. It works, but it turns every allocation site into ownership bookkeeping. I wanted to keep writing ordinary allocating Julia code and still bound the tail. So I built a region-based collector into the runtime (branch of 1.13).

The rules

Regions are ordered by lifetime (Permanent > Engine > Simulation > Event), and there is really only one rule: references may only point from young to old. The rest falls out of it:

  1. An object belongs to one region, chosen at allocation, forever.
  2. Every allocation goes to the dynamically current region, compiler-implicit ones included.
  3. a → b is legal only when b’s region is the same or older. A violating store is a bug, not a hint. A development-mode barrier traps on it exactly.
  4. Resetting a region is legal when nothing live points in (the stack also matters), and by rule 3, no older heap object ever can.
  5. Collecting one region needs only the execution roots, older regions are live by definition and never traced.

The payoff of the rule is the Event region, the young generation of this design. When the events of a slice are processed, nothing can point into their scratch anymore, so there is nothing to mark, nothing to trace, nothing to sweep: “collecting” the young generation is one pointer swap that hands every page back to the allocator. It costs ~21 ns whether the slice touched ten pages or ten thousand (every Nth event). No remembered sets (the edges they’d track are illegal), no whole-heap marking, and the write barrier doesn’t exist in production builds.

The simplified API

region_set(EVENT)           # make a region current; returns the previous one
@with_region EVENT begin    # the same as a scoped block
    process_event!(...)
end
region_reset(EVENT)         # the young-gen "collection": O(1), ~21 ns
region_collect_coop(SIM)    # census: collect one region, no stop-the-world
region_reserve(512_000_000) # claim + prefault the heap: no page fault in the loop

A simulator opens one Event window per slice of events, resets it once per slice, keeps long-lived records in the Simulation region, and runs the census there at a boundary it owns: 36 µs median over 10 000 live records, ~3 ns per live object. The whole API can compile to no-ops, so the same code runs unchanged on the stock GC, which is literally the special case of exactly one region.

Some numbers

Longest pause any event took, 5 million events, ~1.7 KB of garbage per event, isolated core:

stock collector regions + census regions, reset only
4 016 µs 55 µs 15 µs

The 55 µs is the census of the long-lived region, once per 100 000 events. The reset-only column is what the Event region alone costs: the worst event in five million is 15 µs. Same model, one Bool apart: max event latency 4.78 ms with 17 collections vs 9.9 µs with zero. Thirty minutes paced at 100 µs/event: zero missed slots, RSS flat. Every number is reproducible, the scripts, logs, and environment are in the branch.

README
Measurements

Really nice work, I would have love to see how MMTK would do on your case.