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.

I don’t know enough about GC to really understand all the features and tradeoffs you’re describing. But this seems (to naive me) a bit like Bumper.jl but a bit more tightly integrated and handling more allocation types. Maybe?

I quickly checked, and yes it’s similar. One important difference is that in my version normal Julia code can allocate as before. You don’t need to change anything in the code. For example, just wrap a recursive algorithm which allocates all over the place, and it still works as long as the invariant is held.

That’s always been the issue I have with bump allocators; I not only need to rewrite my own code, but other peoples as well. There are tricks like using similar, but that still only works for a slightly larger subset of allocations.

I’d love to see something like this eventually make its way into Julia, thanks for putting this together!

FWIW, this will blow up for innocuous things like push!(long_term_vector, some_bits_type) if the long_term_vector gets resized (newly allocated Memory lives inside region).

Things I’d love to see in mainstream julia:

  1. More generations than just old/young: old/young/ephemeral
  2. Control over these generations: An API function for “collect the ephemeral generation and promote all survivors to young”, and for “maybe do the latter, depending on GC pressure heuristics; otherwise, do nothing”.
  3. Debugging support:
    3.1. Optionally trap whenever a write-barrier is triggered into the ephemeral generation, i.e. when a reference to an ephemeral object is stored into old/young
    3.2. Optionally trap whenever the new API function promotes an object out of ephemeral.

The way to use this would be: At certain strategic points between events, you collect the ephemeral generation. If your invariants hold, then 3.1 and 3.2 never trigger, nothing is marked during GC (very fast mark :wink: ) and ideally the sweep is offloaded to a different thread.

So you get shorter pauses if you structure your code right, and can possibly disable automatic GC (i.e. only permit GC when you explicitly request it).

As opposed to your approach, this will not kill correctness.

If one wanted a minimal PR, stick with 2 generations, then add an API function for “consult GC pressure; either do nothing or collect young generation and promote all survivors”, plus optional traps for debugging. Then disable GC and only use this API function.

PS. The fundamental generational GC issue is for workloads that are “allocate a bunch of objects; then discard them; repeat;”.

This can be as fast as malloc/free plus never-triggered write-barriers. However, if the GC is triggered by allocations, then it can trigger in the middle of “allocate objects”, the already allocated current objects are still alive, and you pay marking latency; and if you then promote them to “old”, then you leaked memory until your next maintenance window for major collections (…maybe once per week? Whenever you can afford to predictably miss deadlines). So this is solvable if we get more control over the GC.

PPS. Of course, arena allocation will absolutely smoke malloc/free, latency-wise. But getting arena-allocation working correctly in julia is very difficult. It’s much easier in C, and even there a lot of projects use individual malloc/free because they can’t be bothered to do the right thing.

You critique is valid and the first version was incomplete on various points.

This crash is fixed now and the whole thing is way more robust. The store is still an error, but a safe and loud one, so yes there are limits. What the barrier does not do is make the code work. Currently a long-lived collection must be grown with its own region current, or call sizehint! before the window.

The updated branch and measurements may be interesting, see here

I agree with your points on the enhancements of the GC, would help a lot.