[ruby-core:126238] [Ruby Feature#22227] Per-Ractor GC: collect each Ractor's heap locally, stop the world only when needed
Issue #22227 has been reported by ko1 (Koichi Sasada). ---------------------------------------- Feature #22227: Per-Ractor GC: collect each Ractor's heap locally, stop the world only when needed https://bugs.ruby-lang.org/issues/22227 * Author: ko1 (Koichi Sasada) * Status: Open ---------------------------------------- Companion PR: https://github.com/ruby/ruby/pull/18194 --- ## Abstract This proposal makes the default GC operate per Ractor. Each Ractor owns an objspace (a private heap) and collects it on its own thread without stopping any other Ractor ("local GC"). A stop-the-world collection over all objspaces ("global GC") runs only when something genuinely global has to be reclaimed: shareable objects, memory retained across Ractor boundaries, and the heaps of dead Ractors. With this change, allocation-heavy Ractor programs scale like forked processes, and Ractor communication can even beat interprocess communication; end-to-end single-Ractor workloads stay within 2-4% of current performance (focused allocation microbenchmarks move a few percent in both directions, see section 4.4). ## 1. Motivation Today all Ractors allocate from one shared objspace, and every GC stops every Ractor. This has two consequences: 1. GC pause time grows with the total heap of all Ractors, while the pause still stops everyone. N allocating Ractors trigger GCs N times as often, and each GC scans N heaps. 2. The synchronization needed to stop N mutators at every GC dominates as N grows. In practice, a program that parses JSON in 16 Ractors spends 10x the wall clock of its single-Ractor version (for the same per-worker work), while the identical code in 16 forked processes stays at the hardware's parallel limit of about 3.3x. Ractors currently lose to fork exactly on the workloads they were designed for. The root cause is architectural, not incremental: CRuby's current shared objspace couples every Ractor to the same stop-the-world GC. This proposal splits the heap. ## 2. Design overview Terminology used below: * **objspace**: one Ractor's private heap: its pages, freelists, mark stack, generational state and counters. * **local GC**: a collection of a single objspace, run by its owner thread, with no VM lock and no barrier. Other Ractors keep running. * **global GC**: a stop-the-world mark/sweep over all objspaces as one heap. Heap traversal is exact; machine-stack roots remain conservative. * **shareable object**: `Ractor.shareable?(obj) == true` (immutable values, Class/Module, shareable Procs, and so on). * **shref** ("shareable reference"): an unshareable object directly referenced by a shareable one. Details are given in 2.1 and 2.3. ### 2.1 The containment invariant The correctness of a local GC rests on one invariant:
An unshareable object is reachable only from its owner Ractor, except through edges that the GC has been explicitly told about (shrefs and in-flight messages).
The invariant has an equally important flip side: a **shareable object can be referenced from any Ractor at any time, and nothing records who references it**. A local GC therefore never collects a shareable object, and never even traverses one; it only marks them all as live (2.3). Only a global GC, whose unified mark sees every Ractor's references, can prove a shareable object dead and reclaim it. In other words: unshareable objects are the local GC's responsibility, shareable objects are exclusively the global GC's. Ruby-level Ractor semantics already forbid directly sharing unshareable objects. CRuby, however, has a small number of implementation-level edges that cross the boundary, and the GC work is to make every one of them visible to the collector: * A shareable object can hold an implementation-level reference to an unshareable one: for example, writing through a shareable Proc's environment stores an unshareable value into a shareable env, and a shareable object's hidden instance variables (its fields imemo) can stay unshareable. The **write barrier** detects every such store and records the target as a **shref**. The transition that makes an object shareable likewise registers any pre-existing outgoing edges of this kind (for example, the hidden field values that `Ractor.make_shareable`'s traversal does not reach are shref-recorded at promotion time). * A message in flight (sent, not yet received) lives in the sender's objspace while being reachable only from queue structures. The send path **pins** the payload in the sender's objspace until the receiver materializes it (2.6). Given the invariant, marking one objspace's roots reaches everything a local GC is allowed to free, so a local GC requires no process-wide barrier and never stops another Ractor; its synchronization is confined to leaf mutexes and atomic bitmap operations on shared metadata (2.3). `GC.verify_internal_consistency` walks the heap and checks the invariant edge by edge (builds with `RGENGC_CHECK_MODE` also run it around every collection); it caught most of the bugs found during development. ### 2.2 Heap layout and ownership Every heap page records its owning objspace, so any object's owner is one load away: `GET_HEAP_OBJSPACE(obj)` reads the page header. There is no per-object tag; ownership is a property of the page. Page bodies come from a process-wide **page pool**: large mmap arenas carved into page-sized bodies and recycled through a freelist under a dedicated mutex. Ractors therefore exchange pages (allocation, death, inheritance) without mmap/munmap churn, and without serializing on the kernel's mmap lock. A process-wide **page index** (all pages of all objspaces, sorted by body address) supports the conservative machine-stack scan of a global GC and ownership tests; only stop-the-world code reads it, so it needs no reader lock. Allocation is a bump-pointer fast path (a cursor advancing through free regions) into per-objspace, per-size-class heaps. The upstream per-Ractor newobj cache became unnecessary (the objspace itself is per Ractor), which makes the allocation fast path about 9% faster. ### 2.3 What a local GC does A local GC is the classic incremental/generational collector of CRuby, restricted to one objspace, plus three rules: 1. **Never free a shareable object.** At the end of marking (`gc_marks_finish`), a dedicated pass (`pinned_roots_mark`) marks every shareable object on the objspace's pages that ordinary marking did not reach: mark bit only, no traversal. Another Ractor may hold the only reference to it; only a global GC can prove a shareable object dead. The count of objects kept alive this way is also a useful retention metric (an upper bound on garbage only a global GC can reclaim). 2. **Treat shrefs as roots.** A shref is marked and traversed like a remembered old-to-young target: the shareable parent is never traversed by a local GC (rule 1), so without this its unshareable children would look unreachable. 3. **Never touch a foreign object's GC state.** Marking stops at any object owned by another objspace ("foreign objects are live leaves"): its mark/age/remembered bits belong to its owner, and writing them would race the owner's local GC. Because objects can become shareable at any time between collections, rule 1 scans the shareable/shref bitmaps in every mark cycle rather than trying to maintain a pin set across sweeps. Generational collection stays per objspace. The remembered-set bitmap has exactly one class of cross-thread writers (another Ractor's write barrier remembering a shareable object), and that path uses an atomic bitmap set; everything else is single-writer under the owner's GVL. **Locking model.** A local GC takes neither the VM lock nor any barrier; it never stops or waits for another Ractor. The main Ractor's local GC is the one exception in two bounded windows: it walks VM-global roots and weak tables (`rb_vm_mark`, freeing entries of fstring/symbol/ci tables), which other Ractors rewrite under the VM lock, so it takes a no-barrier VM lock just for those stretches; and when a JIT is enabled, marking reaches JIT payloads of shareable iseqs, which must exclude concurrent compilation. One rule is absolute: a GC must never take the barrier VM lock mid-collection, because the waiter would join a pending global barrier and expose its half-collected heap to the global GC. Shared structures that GC paths touch (page pool, the generic-fields table, registered globals) use their own leaf mutexes instead. **Triggers.** Every local GC entry first asks whether a *global* cycle is needed instead (see 2.4); otherwise the usual malloc/allocation heuristics drive local minor/major cycles per objspace. ### 2.4 What a global GC does A global GC stops the world using the existing barrier mechanism (a GC has no safepoints, so the barrier implicitly waits for every in-flight local GC to finish). Then, with the driver thread executing every phase: 1. **Settle**: finish every objspace's lazy sweep, so mark bits have a single meaning. 2. **Clear**: clear every objspace's mark bits, remembered sets, age metadata and all shref bits. (Missing any of these would leave stale bits and cause use-after-free; this step is why shref bits may only be cleared by a global GC.) 3. **Unified mark**: mark from every Ractor's roots (C-struct roots, machine stacks scanned conservatively against the global page index, VM globals) and traverse exactly, crossing objspace boundaries freely. The write barrier's shref information is re-derived as a byproduct: each shareable-to-unshareable edge encountered re-records the shref bit. In-flight message payloads are re-pinned here (their shref bits were cleared in step 2). 4. **Weak passes**: the generic-fields table (weak keys) is processed to a fixpoint: values (`fields_obj`) of live keys are marked, entries of dead keys are dropped, and since marking a value can make another key live, the pass repeats until no progress. VM-global weak tables (fstring, symbols, call caches and so on) are swept once, not once per objspace. 5. **Sweep**: sweep every objspace inside the barrier (not lazily). This is the only place shareable objects and cross-objspace cycles die. Emptied pages return to the page pool. 6. **Budget**: objspaces left with no free pages get the same heap-growth budget a local full mark would have granted (a global GC bypasses that bookkeeping), so the next allocation does not fail. 7. Shareable-object populations and trigger limits are recounted per objspace. Global GCs are triggered by: an explicit full `GC.start`; growth of the shareable-object population past a per-objspace limit; and accumulation of heap pages held by dead Ractors (see 2.5). Two Ractors deciding to run a global GC at the same time are serialized by the barrier; the second cycle is wasted work, not an error. **Compaction.** With multiple objspaces, `GC.compact` (and autocompact on full marks) runs inside a global GC as three passes over all objspaces: (a) move every objspace's movable objects, leaving forwarding; (b) update references everywhere, following forwarding across objspaces, plus the VM-global side once; (c) sweep and free emptied source pages. It must be three global passes rather than per-objspace work because a reference can point at a moved object in another objspace, and freeing a source page early would let another objspace's update read freed forwarding. A local (non-stop-the-world) compaction runs only while the process has a single objspace; objects that C structs of other Ractors point at (an in-flight payload, a terminated Ractor's pending value) are pinned so they never move even under global compaction. Incremental marking similarly runs only in single-objspace mode; the transition point (`Ractor.new` making the process multi-Ractor) settles any cycle in progress. ### 2.5 Ractor lifecycle and memory hand-off **Creation.** A Ractor's objspace is created before its thread starts, and the child's main Thread and root Fiber wrapper objects are allocated directly into it. Between those allocations and the child joining the Ractor set, the creator "covers" the child objspace through a dedicated slot so a concurrent global GC still enumerates it (an unenumerated objspace would keep stale mark bits). Every failure path of creation (isolation errors, thread-creation failure) hands the child objspace over for reclamation rather than leaking it. **Termination.** A terminating Ractor runs one last local GC with a by-then minimal root set on its own thread ("retire GC": roots are minimal by then, and this reclaims garbage that would otherwise be inherited), and its objspace becomes a **zombie**: registered in a VM-wide table, mutated by nobody, but enumerated by every global GC. Ractor death therefore does not stop the world. **Inheritance.** `Ractor#value` (and join) *absorbs* the dead Ractor's objspace into the caller's: pages are spliced wholesale into the caller's heaps (kept sorted by body address for the conservative scan), the finalizer table entries move over, malloc-accounting counters transfer, and the caller's next collection is forced to a full mark to rebuild generational state. This is why `Ractor#value` can return the value **by reference** with no copy: after the absorb it is an ordinary object of the caller. If a dead Ractor's object is itself collected without ever being joined, its orphan objspace is merged into the main Ractor at main's next safepoint (scheduled as a postponed job, because the discovery happens inside a sweep). At VM shutdown all remaining zombies merge into main so at-exit processing sees every object. Because a zombie's pages still hold shareable objects that other Ractors can reach, zombies participate in the global-GC trigger: accumulated zombie pages past a threshold start a global cycle (measured against what survived the previous one, so a zombie with much live data does not retrigger forever). ### 2.6 Message passing: copy `Ractor#send` (copy semantics) became a two-phase protocol: 1. **Snapshot (sender side).** The sender deep-copies the object graph inside its own objspace using a native copier for core types (String, Array, Hash, Struct, T_OBJECT and so on); types the native copier does not support fall back to a Marshal round trip. `#clone` and `initialize_copy` are never invoked, although Marshal-specific hooks (`marshal_dump`, `_dump`) run on the sender for fallback types and can raise there. Every node of the snapshot is **pinned** in the sender's objspace, and the pin cover is handed off without gaps: from the builder's capture list, to the basket (queue entry), to the receiver's materialization frame. A global GC re-pins from whichever holder is current (its clear pass drops all shref bits, see 2.4 step 2/3). 2. **Materialize (receiver side).** When the receiver takes the message out of the port, it rebuilds the graph inside its own objspace. The sender-resident snapshot becomes garbage once the copy completes. Materialization can raise (Marshal load hooks and autoload run user code; asynchronous interrupts can arrive) and can nest (`Ractor.receive` inside a load hook), so in-progress materializations form a per-EC chain of machine-stack frames that the GC uses as roots. Passing the sender-resident graph to the receiver by reference is never allowed: it would create an unshareable cross-objspace edge that neither local GC could handle. ### 2.7 Message passing: move `Ractor#send(obj, move: true)` serializes the payload graph into an off-heap, malloc-allocated structure called the **courier**. While a moved graph is in flight, there is *no heap object* representing it: neither side's GC can mark, sweep, move, or race with it. * A read-only **preflight** walks the graph first and raises every "can not move" error before anything is mutated (capture is destructive, so failing halfway would corrupt the graph beyond repair). * **Capture** turns each source object into a node in the courier's node array (identity and cycles are preserved through node ids and a by-address dedup table) and immediately neutralizes the source into a `Ractor::MovedObject` shell, without ever passing through an inconsistent state that a concurrent marker could observe. * Payload internals move by ownership transfer where possible: a String that owns its buffer hands the pointer over (zero copy); an IO carries its whole `rb_io_t` including the file descriptor; MatchData dumps its match registers into an onig-independent blob. Shareable references and immediates are carried as values; classes of the moved objects are carried as references (classes are shareable), so subclasses and singleton classes survive the move. * In-flight couriers are registered in a VM-global list; a global GC's root pass marks and pins the shareable references they carry (nothing else roots them in some windows). * **Rebuild** on the receiver allocates all shells first, then fills references (two passes break cycles), and inserts Hash entries last, in reverse id order, so user-defined `#hash` methods observe fully built keys. Rebuilding can raise; an unconsumed courier is freed by the queue teardown, closing descriptor leaks for moved IOs. ### 2.8 Generic instance variables Instance variables of non-T_OBJECT hosts (`str.instance_variable_set` and friends) live in one process-global table keyed weakly by host object, guarded by a dedicated leaf mutex. A local GC reads it per object while marking (it cannot wait for the VM lock there); the global GC processes it in the weak pass described above; per-object cleanup happens when a host is freed. An earlier design with per-Ractor tables plus a shared table was abandoned: entries would have had to migrate on every make-shareable, send and inheritance, and that boundary was a reliable source of bugs. ### 2.9 Modular GC API The GC implementation interface (`gc/gc_impl.h`) gains per-Ractor entry points: `multi_objspace_p`, `objspace_absorb`, `objspace_retire_gc`, `obj_foreign_p`, `during_global_gc_p`, `obj_became_shareable`, `pin_in_flight_message`, `each_objects_shareable`, `each_objects_foreign`, `heap_page_count`, `gc_rest`. An implementation that keeps a single objspace (MMTk) reports `multi_objspace_p() == false` and the VM gates the whole per-Ractor machinery out: every Ractor aliases the one objspace, retire/absorb/zombie handling become no-ops, and Ractor-related marking falls back to following everything from the wrapper objects. MMTk continues to pass its test suite unchanged. ## 3. Compatibility * `Ractor#value` becomes a one-shot ownership transfer: the first call hands the value over by reference together with the dead Ractor's heap, and a second call raises `Ractor::Error` ("The value was already taken"). Returning the same unshareable object again would hand out a reference to an object now owned by the first caller, recreating exactly the cross-Ractor sharing that Ractor semantics forbid; special-casing repeat calls from the same Ractor, or shareable values only, would make the API's behavior depend on the caller and the value's type, so the simple rule was chosen. (Previously repeated calls returned the same object.) * `GC.start` in a multi-Ractor process runs a stop-the-world global GC; automatic GC in a Ractor collects only its own heap. * `GC.disable`/`GC.enable` become per-Ractor holds on a process-wide switch: any Ractor's disable stops automatic GC everywhere, but another Ractor's `GC.enable` no longer silently overrides it. A Ractor's hold is released when it exits. * `ObjectSpace.each_object` enumerates the calling Ractor's own objects plus other live Ractors' shareable objects, never their unshareable ones. Objects of a terminated but not yet inherited Ractor are not enumerated until its heap is absorbed. (`ObjectSpace.dump_all` and `memsize_of_all` still cover every object in the process; they walk the other objspaces under a global barrier.) * `ObjectSpace.define_finalizer` on another Ractor's object raises `Ractor::IsolationError`; finalizers are registered, stored and run in the owner's objspace. * The `RUBY_INTERNAL_EVENT_FREEOBJ` tracepoint fires only for the main Ractor's objspace (it runs user callbacks from inside the sweep, which is unsafe in a lock-free non-main sweep; extending it is future work). * Invalidated call-cache entries are no longer pruned during GC marking; they are reclaimed at the next lookup of that method or when the class dies. Retention is bounded (one entry per class-and-method-id pair); the worst-case microbenchmark retains approximately 2.7 MB for 200,000 stale entries. ## 4. Performance Setup: 16 logical CPUs (8 cores, SMT), x86_64-linux, `-O3` release builds. `master` is the merge-base of the PR, `RLGC` is the branch. Every cell is wall-clock seconds until all N workers finish (lower is better). Because the machine's clock state drifts on multi-minute scales, each RLGC/master pair was measured interleaved back to back, and each number is the minimum over at least 10 runs spread across several sessions. All three programs are weak-scaling benchmarks: every worker runs the full workload independently, so with perfect parallelism the wall clock stays flat as N grows. The ratio tables divide every cell by master-Ractor N=1, i.e. "how many times the wall time of current Ruby running one Ractor"; a perfectly scaling configuration keeps its N=1 ratio at every N. "Ractor" rows run the N workers as Ractors, "fork" rows run the identical code in forked processes: the no-shared-GC baseline that Ractors should match. Scripts are about 40 lines each and attached to the PR. ### 4.1 binary-trees (D=16; allocation/GC-heavy) The classic benchmarks-game workload: each worker builds and checksums millions of short-lived binary trees (3-element arrays). Allocation and GC dominate; objects die young. seconds: | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 3.83 | 4.01 | 4.48 | 5.76 | 10.26 | | master Ractor | 3.65 | 4.18 | 5.32 | 8.18 | 14.90 | | RLGC fork | 3.75 | 3.99 | 4.48 | 6.56 | 10.22 | | master fork | 3.66 | 3.90 | 4.40 | 6.40 | 10.03 | ratio (x master-Ractor N=1): | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 1.05 | 1.10 | 1.23 | 1.58 | **2.81** | | master Ractor | 1.00 | 1.15 | 1.46 | 2.24 | 4.08 | | RLGC fork | 1.03 | 1.09 | 1.23 | 1.80 | 2.80 | | master fork | 1.00 | 1.07 | 1.21 | 1.75 | 2.75 | RLGC Ractors scale exactly like forked processes (2.81 vs 2.75-2.80 at N=16); master Ractors are 1.45x behind at N=16. For calibration, a zero-allocation pure-integer loop on this machine goes from 5.9s (N=1) to 10.4s (N=16), so RLGC's 10.26s at N=16 is the hardware's SMT ceiling, not a GC limit. ### 4.2 JSON parse (embarrassingly parallel) Each worker parses its own copy of the same ~30 KB JSON document (200 nested user records) 8000 times and discards each result: pure parse-allocate-discard churn, no data crosses workers. This isolates how allocation and GC scale. seconds: | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 1.59 | 1.70 | 1.89 | 2.61 | 4.93 | | master Ractor | 1.49 | 2.06 | 2.63 | 5.24 | 15.10 | | RLGC fork | 1.50 | 1.73 | 1.88 | 2.53 | 4.87 | | master fork | 1.48 | 1.64 | 1.81 | 2.48 | 4.94 | ratio (x master-Ractor N=1): | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 1.07 | 1.14 | 1.27 | 1.76 | **3.32** | | master Ractor | 1.00 | 1.39 | 1.77 | 3.53 | 10.17 | | RLGC fork | 1.01 | 1.17 | 1.27 | 1.71 | 3.28 | | master fork | 0.99 | 1.10 | 1.22 | 1.67 | 3.33 | master Ractors fall off almost immediately (1.39x already at N=2: every Ractor's allocation churn stops all the others) and end 10x over the baseline at N=16; RLGC matches the fork rows within noise the whole way and finishes 3.1x faster than master Ractors. ### 4.3 JSON producer/consumer (Ractor move vs process IPC) The "coordinator hands work to parsers" pattern: the main Ractor/process produces 20000xN JSON strings (~6 KB each) and distributes them round-robin to N workers, which parse and discard them. The Ractor version transfers each string with `send(move: true)`; the fork version writes it down a pipe (a kernel copy). This measures the data-transfer path, not just parallel GC. seconds: | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 0.77 | 0.89 | 1.05 | 1.59 | 2.71 | | master Ractor | 0.71 | 0.99 | 1.68 | 3.87 | 14.59 | | RLGC fork | 0.80 | 0.85 | 0.98 | 1.87 | 3.02 | | master fork | 0.74 | 0.80 | 0.97 | 1.83 | 3.04 | ratio (x master-Ractor N=1): | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 1.08 | 1.25 | 1.48 | 2.25 | **3.82** | | master Ractor | 1.00 | 1.40 | 2.37 | 5.45 | 20.55 | | RLGC fork | 1.12 | 1.19 | 1.39 | 2.63 | 4.25 | | master fork | 1.04 | 1.12 | 1.37 | 2.58 | 4.29 | At N=16 RLGC's `move` beats even process IPC (2.71s vs 3.02-3.04s: no kernel copy), while master Ractors are 5.4x behind it. The off-heap courier costs ~8% over master's in-heap move at N=1 (0.77 vs 0.71); that is the price of making in-flight messages invisible to both sides' GCs, and a zero-copy fast path remains possible future work. ### 4.4 Single-Ractor performance Measured with no `Ractor.new` at all (the N=1 rows above already run one worker Ractor, which puts the process in multi-Ractor mode). The allocation fast path itself got faster: the per-Ractor newobj cache is gone and objects come straight off the objspace's bump-pointer heaps. | single Ractor, interleaved minima | RLGC | master | | |---------------------------------------|------|--------|------| | `Object.new` x 20M, GC disabled | 1.83 | 2.01 | **-9%** | | `Object.new` x 20M, GC enabled | 1.18 | 1.10 | +7% | | binary-trees (D=16) | 3.79 | 3.71 | +2% | | JSON parse (8000 docs) | 1.55 | 1.49 | +4% | (`Object.new` rows subtract the loop-only baseline of 0.24s.) Pure allocation wins by 9%; sweep-time bitmap maintenance (the shareable and shref bitmaps) gives ~7% back under allocate-and-discard churn, which nets out to +2-4% on GC-heavy end-to-end workloads and less on anything that does real work between allocations. The multi-objspace machinery is gated behind a single-objspace check on hot paths. ### 4.5 Latency Beyond throughput, the qualitative change is who pauses: on master every GC of any Ractor stops all of them, so one allocation-heavy Ractor imposes its GC pauses on every other Ractor in the process. With per-Ractor GC a Ractor pauses only for its own collections; the remaining process-wide pauses are explicit `GC.start`, shareable-population growth and dead-Ractor cleanup, all of which are rare in steady state. ## 5. Limitations and future work * Incremental marking and non-stop-the-world compaction currently run only while the process has a single objspace. Per-objspace incremental marking is possible in principle (the machinery is per objspace already) but needs the single-to-multi transition handled mid-cycle. * `move:` uses an off-heap serialization; a zero-copy fast path (moving whole pages for large graphs) is future work, as is extending the FREEOBJ tracepoint beyond the main Ractor. * A global GC is still fully stop-the-world; making it concurrent or incremental is orthogonal future work. ## 6. Validation `make btest` (2054 tests) and `make test-all` are green on x86_64-linux across release, `RUBY_DEBUG=1`, ASAN, TSAN and MMTk builds. `GC.verify_internal_consistency` includes the containment verifier (`RGENGC_CHECK_MODE` builds run it around every collection); it was used throughout development. The branch adds ~90 Ractor/GC tests covering lifecycle, copy/move semantics, compaction interaction, and regressions for every bug found during a multi-week stress campaign (a corpus of ~1000 generated Ractor programs run under GC.stress, ASAN and TSAN). ## Notes * This work completes the design presented in [Toward Ractor local GC (RubyKaigi 2025)](https://rubykaigi.org/2025/presentations/ko1.html). * Much of this description, and a large part of the code, was written with Claude Code (Fable/Opus). * Acknowledgements * This work started as a collaboration with Rohit Menon. * Thanks to the Shopify Ractor team for their feedback. * And thank you, everyone, for waiting so long for this work. -- https://bugs.ruby-lang.org/
Issue #22227 has been updated by byroot (Jean Boussier).
GC.start in a multi-Ractor process runs a stop-the-world global GC; automatic GC in a Ractor collects only its own heap.
Should `GC.start` gain some sort of `local_only: true` argument? I could see this as useful for applications that are still predominantly relying on prefork, hence benefit a lot from Out of Band Garbage Collection (`GC.config(rgengc_allow_full_mark: false)` [Feature #20443](https://bugs.ruby-lang.org/issues/20443)), but do have a couple background ractors in each process. That would probably ease the migration from one model to the other. ---------------------------------------- Feature #22227: Per-Ractor GC: collect each Ractor's heap locally, stop the world only when needed https://bugs.ruby-lang.org/issues/22227#change-118327 * Author: ko1 (Koichi Sasada) * Status: Open ---------------------------------------- Companion PR: https://github.com/ruby/ruby/pull/18194 --- ## Abstract This proposal makes the default GC operate per Ractor. Each Ractor owns an objspace (a private heap) and collects it on its own thread without stopping any other Ractor ("local GC"). A stop-the-world collection over all objspaces ("global GC") runs only when something genuinely global has to be reclaimed: shareable objects, memory retained across Ractor boundaries, and the heaps of dead Ractors. With this change, allocation-heavy Ractor programs scale like forked processes, and Ractor communication can even beat interprocess communication; end-to-end single-Ractor workloads stay within 2-4% of current performance (focused allocation microbenchmarks move a few percent in both directions, see section 4.4). ## 1. Motivation Today all Ractors allocate from one shared objspace, and every GC stops every Ractor. This has two consequences: 1. GC pause time grows with the total heap of all Ractors, while the pause still stops everyone. N allocating Ractors trigger GCs N times as often, and each GC scans N heaps. 2. The synchronization needed to stop N mutators at every GC dominates as N grows. In practice, a program that parses JSON in 16 Ractors spends 10x the wall clock of its single-Ractor version (for the same per-worker work), while the identical code in 16 forked processes stays at the hardware's parallel limit of about 3.3x. Ractors currently lose to fork exactly on the workloads they were designed for. The root cause is architectural, not incremental: CRuby's current shared objspace couples every Ractor to the same stop-the-world GC. This proposal splits the heap. ## 2. Design overview Terminology used below: * **objspace**: one Ractor's private heap: its pages, freelists, mark stack, generational state and counters. * **local GC**: a collection of a single objspace, run by its owner thread, with no VM lock and no barrier. Other Ractors keep running. * **global GC**: a stop-the-world mark/sweep over all objspaces as one heap. Heap traversal is exact; machine-stack roots remain conservative. * **shareable object**: `Ractor.shareable?(obj) == true` (immutable values, Class/Module, shareable Procs, and so on). * **shref** ("shareable reference"): an unshareable object directly referenced by a shareable one. Details are given in 2.1 and 2.3. ### 2.1 The containment invariant The correctness of a local GC rests on one invariant:
An unshareable object is reachable only from its owner Ractor, except through edges that the GC has been explicitly told about (shrefs and in-flight messages).
The invariant has an equally important flip side: a **shareable object can be referenced from any Ractor at any time, and nothing records who references it**. A local GC therefore never collects a shareable object, and never even traverses one; it only marks them all as live (2.3). Only a global GC, whose unified mark sees every Ractor's references, can prove a shareable object dead and reclaim it. In other words: unshareable objects are the local GC's responsibility, shareable objects are exclusively the global GC's. Ruby-level Ractor semantics already forbid directly sharing unshareable objects. CRuby, however, has a small number of implementation-level edges that cross the boundary, and the GC work is to make every one of them visible to the collector: * A shareable object can hold an implementation-level reference to an unshareable one: for example, writing through a shareable Proc's environment stores an unshareable value into a shareable env, and a shareable object's hidden instance variables (its fields imemo) can stay unshareable. The **write barrier** detects every such store and records the target as a **shref**. The transition that makes an object shareable likewise registers any pre-existing outgoing edges of this kind (for example, the hidden field values that `Ractor.make_shareable`'s traversal does not reach are shref-recorded at promotion time). * A message in flight (sent, not yet received) lives in the sender's objspace while being reachable only from queue structures. The send path **pins** the payload in the sender's objspace until the receiver materializes it (2.6). Given the invariant, marking one objspace's roots reaches everything a local GC is allowed to free, so a local GC requires no process-wide barrier and never stops another Ractor; its synchronization is confined to leaf mutexes and atomic bitmap operations on shared metadata (2.3). `GC.verify_internal_consistency` walks the heap and checks the invariant edge by edge (builds with `RGENGC_CHECK_MODE` also run it around every collection); it caught most of the bugs found during development. ### 2.2 Heap layout and ownership Every heap page records its owning objspace, so any object's owner is one load away: `GET_HEAP_OBJSPACE(obj)` reads the page header. There is no per-object tag; ownership is a property of the page. Page bodies come from a process-wide **page pool**: large mmap arenas carved into page-sized bodies and recycled through a freelist under a dedicated mutex. Ractors therefore exchange pages (allocation, death, inheritance) without mmap/munmap churn, and without serializing on the kernel's mmap lock. A process-wide **page index** (all pages of all objspaces, sorted by body address) supports the conservative machine-stack scan of a global GC and ownership tests; only stop-the-world code reads it, so it needs no reader lock. Allocation is a bump-pointer fast path (a cursor advancing through free regions) into per-objspace, per-size-class heaps. The upstream per-Ractor newobj cache became unnecessary (the objspace itself is per Ractor), which makes the allocation fast path about 9% faster. ### 2.3 What a local GC does A local GC is the classic incremental/generational collector of CRuby, restricted to one objspace, plus three rules: 1. **Never free a shareable object.** At the end of marking (`gc_marks_finish`), a dedicated pass (`pinned_roots_mark`) marks every shareable object on the objspace's pages that ordinary marking did not reach: mark bit only, no traversal. Another Ractor may hold the only reference to it; only a global GC can prove a shareable object dead. The count of objects kept alive this way is also a useful retention metric (an upper bound on garbage only a global GC can reclaim). 2. **Treat shrefs as roots.** A shref is marked and traversed like a remembered old-to-young target: the shareable parent is never traversed by a local GC (rule 1), so without this its unshareable children would look unreachable. 3. **Never touch a foreign object's GC state.** Marking stops at any object owned by another objspace ("foreign objects are live leaves"): its mark/age/remembered bits belong to its owner, and writing them would race the owner's local GC. Because objects can become shareable at any time between collections, rule 1 scans the shareable/shref bitmaps in every mark cycle rather than trying to maintain a pin set across sweeps. Generational collection stays per objspace. The remembered-set bitmap has exactly one class of cross-thread writers (another Ractor's write barrier remembering a shareable object), and that path uses an atomic bitmap set; everything else is single-writer under the owner's GVL. **Locking model.** A local GC takes neither the VM lock nor any barrier; it never stops or waits for another Ractor. The main Ractor's local GC is the one exception in two bounded windows: it walks VM-global roots and weak tables (`rb_vm_mark`, freeing entries of fstring/symbol/ci tables), which other Ractors rewrite under the VM lock, so it takes a no-barrier VM lock just for those stretches; and when a JIT is enabled, marking reaches JIT payloads of shareable iseqs, which must exclude concurrent compilation. One rule is absolute: a GC must never take the barrier VM lock mid-collection, because the waiter would join a pending global barrier and expose its half-collected heap to the global GC. Shared structures that GC paths touch (page pool, the generic-fields table, registered globals) use their own leaf mutexes instead. **Triggers.** Every local GC entry first asks whether a *global* cycle is needed instead (see 2.4); otherwise the usual malloc/allocation heuristics drive local minor/major cycles per objspace. ### 2.4 What a global GC does A global GC stops the world using the existing barrier mechanism (a GC has no safepoints, so the barrier implicitly waits for every in-flight local GC to finish). Then, with the driver thread executing every phase: 1. **Settle**: finish every objspace's lazy sweep, so mark bits have a single meaning. 2. **Clear**: clear every objspace's mark bits, remembered sets, age metadata and all shref bits. (Missing any of these would leave stale bits and cause use-after-free; this step is why shref bits may only be cleared by a global GC.) 3. **Unified mark**: mark from every Ractor's roots (C-struct roots, machine stacks scanned conservatively against the global page index, VM globals) and traverse exactly, crossing objspace boundaries freely. The write barrier's shref information is re-derived as a byproduct: each shareable-to-unshareable edge encountered re-records the shref bit. In-flight message payloads are re-pinned here (their shref bits were cleared in step 2). 4. **Weak passes**: the generic-fields table (weak keys) is processed to a fixpoint: values (`fields_obj`) of live keys are marked, entries of dead keys are dropped, and since marking a value can make another key live, the pass repeats until no progress. VM-global weak tables (fstring, symbols, call caches and so on) are swept once, not once per objspace. 5. **Sweep**: sweep every objspace inside the barrier (not lazily). This is the only place shareable objects and cross-objspace cycles die. Emptied pages return to the page pool. 6. **Budget**: objspaces left with no free pages get the same heap-growth budget a local full mark would have granted (a global GC bypasses that bookkeeping), so the next allocation does not fail. 7. Shareable-object populations and trigger limits are recounted per objspace. Global GCs are triggered by: an explicit full `GC.start`; growth of the shareable-object population past a per-objspace limit; and accumulation of heap pages held by dead Ractors (see 2.5). Two Ractors deciding to run a global GC at the same time are serialized by the barrier; the second cycle is wasted work, not an error. **Compaction.** With multiple objspaces, `GC.compact` (and autocompact on full marks) runs inside a global GC as three passes over all objspaces: (a) move every objspace's movable objects, leaving forwarding; (b) update references everywhere, following forwarding across objspaces, plus the VM-global side once; (c) sweep and free emptied source pages. It must be three global passes rather than per-objspace work because a reference can point at a moved object in another objspace, and freeing a source page early would let another objspace's update read freed forwarding. A local (non-stop-the-world) compaction runs only while the process has a single objspace; objects that C structs of other Ractors point at (an in-flight payload, a terminated Ractor's pending value) are pinned so they never move even under global compaction. Incremental marking similarly runs only in single-objspace mode; the transition point (`Ractor.new` making the process multi-Ractor) settles any cycle in progress. ### 2.5 Ractor lifecycle and memory hand-off **Creation.** A Ractor's objspace is created before its thread starts, and the child's main Thread and root Fiber wrapper objects are allocated directly into it. Between those allocations and the child joining the Ractor set, the creator "covers" the child objspace through a dedicated slot so a concurrent global GC still enumerates it (an unenumerated objspace would keep stale mark bits). Every failure path of creation (isolation errors, thread-creation failure) hands the child objspace over for reclamation rather than leaking it. **Termination.** A terminating Ractor runs one last local GC with a by-then minimal root set on its own thread ("retire GC": roots are minimal by then, and this reclaims garbage that would otherwise be inherited), and its objspace becomes a **zombie**: registered in a VM-wide table, mutated by nobody, but enumerated by every global GC. Ractor death therefore does not stop the world. **Inheritance.** `Ractor#value` (and join) *absorbs* the dead Ractor's objspace into the caller's: pages are spliced wholesale into the caller's heaps (kept sorted by body address for the conservative scan), the finalizer table entries move over, malloc-accounting counters transfer, and the caller's next collection is forced to a full mark to rebuild generational state. This is why `Ractor#value` can return the value **by reference** with no copy: after the absorb it is an ordinary object of the caller. If a dead Ractor's object is itself collected without ever being joined, its orphan objspace is merged into the main Ractor at main's next safepoint (scheduled as a postponed job, because the discovery happens inside a sweep). At VM shutdown all remaining zombies merge into main so at-exit processing sees every object. Because a zombie's pages still hold shareable objects that other Ractors can reach, zombies participate in the global-GC trigger: accumulated zombie pages past a threshold start a global cycle (measured against what survived the previous one, so a zombie with much live data does not retrigger forever). ### 2.6 Message passing: copy `Ractor#send` (copy semantics) became a two-phase protocol: 1. **Snapshot (sender side).** The sender deep-copies the object graph inside its own objspace using a native copier for core types (String, Array, Hash, Struct, T_OBJECT and so on); types the native copier does not support fall back to a Marshal round trip. `#clone` and `initialize_copy` are never invoked, although Marshal-specific hooks (`marshal_dump`, `_dump`) run on the sender for fallback types and can raise there. Every node of the snapshot is **pinned** in the sender's objspace, and the pin cover is handed off without gaps: from the builder's capture list, to the basket (queue entry), to the receiver's materialization frame. A global GC re-pins from whichever holder is current (its clear pass drops all shref bits, see 2.4 step 2/3). 2. **Materialize (receiver side).** When the receiver takes the message out of the port, it rebuilds the graph inside its own objspace. The sender-resident snapshot becomes garbage once the copy completes. Materialization can raise (Marshal load hooks and autoload run user code; asynchronous interrupts can arrive) and can nest (`Ractor.receive` inside a load hook), so in-progress materializations form a per-EC chain of machine-stack frames that the GC uses as roots. Passing the sender-resident graph to the receiver by reference is never allowed: it would create an unshareable cross-objspace edge that neither local GC could handle. ### 2.7 Message passing: move `Ractor#send(obj, move: true)` serializes the payload graph into an off-heap, malloc-allocated structure called the **courier**. While a moved graph is in flight, there is *no heap object* representing it: neither side's GC can mark, sweep, move, or race with it. * A read-only **preflight** walks the graph first and raises every "can not move" error before anything is mutated (capture is destructive, so failing halfway would corrupt the graph beyond repair). * **Capture** turns each source object into a node in the courier's node array (identity and cycles are preserved through node ids and a by-address dedup table) and immediately neutralizes the source into a `Ractor::MovedObject` shell, without ever passing through an inconsistent state that a concurrent marker could observe. * Payload internals move by ownership transfer where possible: a String that owns its buffer hands the pointer over (zero copy); an IO carries its whole `rb_io_t` including the file descriptor; MatchData dumps its match registers into an onig-independent blob. Shareable references and immediates are carried as values; classes of the moved objects are carried as references (classes are shareable), so subclasses and singleton classes survive the move. * In-flight couriers are registered in a VM-global list; a global GC's root pass marks and pins the shareable references they carry (nothing else roots them in some windows). * **Rebuild** on the receiver allocates all shells first, then fills references (two passes break cycles), and inserts Hash entries last, in reverse id order, so user-defined `#hash` methods observe fully built keys. Rebuilding can raise; an unconsumed courier is freed by the queue teardown, closing descriptor leaks for moved IOs. ### 2.8 Generic instance variables Instance variables of non-T_OBJECT hosts (`str.instance_variable_set` and friends) live in one process-global table keyed weakly by host object, guarded by a dedicated leaf mutex. A local GC reads it per object while marking (it cannot wait for the VM lock there); the global GC processes it in the weak pass described above; per-object cleanup happens when a host is freed. An earlier design with per-Ractor tables plus a shared table was abandoned: entries would have had to migrate on every make-shareable, send and inheritance, and that boundary was a reliable source of bugs. ### 2.9 Modular GC API The GC implementation interface (`gc/gc_impl.h`) gains per-Ractor entry points: `multi_objspace_p`, `objspace_absorb`, `objspace_retire_gc`, `obj_foreign_p`, `during_global_gc_p`, `obj_became_shareable`, `pin_in_flight_message`, `each_objects_shareable`, `each_objects_foreign`, `heap_page_count`, `gc_rest`. An implementation that keeps a single objspace (MMTk) reports `multi_objspace_p() == false` and the VM gates the whole per-Ractor machinery out: every Ractor aliases the one objspace, retire/absorb/zombie handling become no-ops, and Ractor-related marking falls back to following everything from the wrapper objects. MMTk continues to pass its test suite unchanged. ## 3. Compatibility * `Ractor#value` becomes a one-shot ownership transfer: the first call hands the value over by reference together with the dead Ractor's heap, and a second call raises `Ractor::Error` ("The value was already taken"). Returning the same unshareable object again would hand out a reference to an object now owned by the first caller, recreating exactly the cross-Ractor sharing that Ractor semantics forbid; special-casing repeat calls from the same Ractor, or shareable values only, would make the API's behavior depend on the caller and the value's type, so the simple rule was chosen. (Previously repeated calls returned the same object.) * `GC.start` in a multi-Ractor process runs a stop-the-world global GC; automatic GC in a Ractor collects only its own heap. * `GC.disable`/`GC.enable` become per-Ractor holds on a process-wide switch: any Ractor's disable stops automatic GC everywhere, but another Ractor's `GC.enable` no longer silently overrides it. A Ractor's hold is released when it exits. * `ObjectSpace.each_object` enumerates the calling Ractor's own objects plus other live Ractors' shareable objects, never their unshareable ones. Objects of a terminated but not yet inherited Ractor are not enumerated until its heap is absorbed. (`ObjectSpace.dump_all` and `memsize_of_all` still cover every object in the process; they walk the other objspaces under a global barrier.) * `ObjectSpace.define_finalizer` on another Ractor's object raises `Ractor::IsolationError`; finalizers are registered, stored and run in the owner's objspace. * The `RUBY_INTERNAL_EVENT_FREEOBJ` tracepoint fires only for the main Ractor's objspace (it runs user callbacks from inside the sweep, which is unsafe in a lock-free non-main sweep; extending it is future work). * Invalidated call-cache entries are no longer pruned during GC marking; they are reclaimed at the next lookup of that method or when the class dies. Retention is bounded (one entry per class-and-method-id pair); the worst-case microbenchmark retains approximately 2.7 MB for 200,000 stale entries. ## 4. Performance Setup: 16 logical CPUs (8 cores, SMT), x86_64-linux, `-O3` release builds. `master` is the merge-base of the PR, `RLGC` is the branch. Every cell is wall-clock seconds until all N workers finish (lower is better). Because the machine's clock state drifts on multi-minute scales, each RLGC/master pair was measured interleaved back to back, and each number is the minimum over at least 10 runs spread across several sessions. All three programs are weak-scaling benchmarks: every worker runs the full workload independently, so with perfect parallelism the wall clock stays flat as N grows. The ratio tables divide every cell by master-Ractor N=1, i.e. "how many times the wall time of current Ruby running one Ractor"; a perfectly scaling configuration keeps its N=1 ratio at every N. "Ractor" rows run the N workers as Ractors, "fork" rows run the identical code in forked processes: the no-shared-GC baseline that Ractors should match. Scripts are about 40 lines each and attached to the PR. ### 4.1 binary-trees (D=16; allocation/GC-heavy) The classic benchmarks-game workload: each worker builds and checksums millions of short-lived binary trees (3-element arrays). Allocation and GC dominate; objects die young. seconds: | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 3.83 | 4.01 | 4.48 | 5.76 | 10.26 | | master Ractor | 3.65 | 4.18 | 5.32 | 8.18 | 14.90 | | RLGC fork | 3.75 | 3.99 | 4.48 | 6.56 | 10.22 | | master fork | 3.66 | 3.90 | 4.40 | 6.40 | 10.03 | ratio (x master-Ractor N=1): | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 1.05 | 1.10 | 1.23 | 1.58 | **2.81** | | master Ractor | 1.00 | 1.15 | 1.46 | 2.24 | 4.08 | | RLGC fork | 1.03 | 1.09 | 1.23 | 1.80 | 2.80 | | master fork | 1.00 | 1.07 | 1.21 | 1.75 | 2.75 | RLGC Ractors scale exactly like forked processes (2.81 vs 2.75-2.80 at N=16); master Ractors are 1.45x behind at N=16. For calibration, a zero-allocation pure-integer loop on this machine goes from 5.9s (N=1) to 10.4s (N=16), so RLGC's 10.26s at N=16 is the hardware's SMT ceiling, not a GC limit. ### 4.2 JSON parse (embarrassingly parallel) Each worker parses its own copy of the same ~30 KB JSON document (200 nested user records) 8000 times and discards each result: pure parse-allocate-discard churn, no data crosses workers. This isolates how allocation and GC scale. seconds: | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 1.59 | 1.70 | 1.89 | 2.61 | 4.93 | | master Ractor | 1.49 | 2.06 | 2.63 | 5.24 | 15.10 | | RLGC fork | 1.50 | 1.73 | 1.88 | 2.53 | 4.87 | | master fork | 1.48 | 1.64 | 1.81 | 2.48 | 4.94 | ratio (x master-Ractor N=1): | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 1.07 | 1.14 | 1.27 | 1.76 | **3.32** | | master Ractor | 1.00 | 1.39 | 1.77 | 3.53 | 10.17 | | RLGC fork | 1.01 | 1.17 | 1.27 | 1.71 | 3.28 | | master fork | 0.99 | 1.10 | 1.22 | 1.67 | 3.33 | master Ractors fall off almost immediately (1.39x already at N=2: every Ractor's allocation churn stops all the others) and end 10x over the baseline at N=16; RLGC matches the fork rows within noise the whole way and finishes 3.1x faster than master Ractors. ### 4.3 JSON producer/consumer (Ractor move vs process IPC) The "coordinator hands work to parsers" pattern: the main Ractor/process produces 20000xN JSON strings (~6 KB each) and distributes them round-robin to N workers, which parse and discard them. The Ractor version transfers each string with `send(move: true)`; the fork version writes it down a pipe (a kernel copy). This measures the data-transfer path, not just parallel GC. seconds: | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 0.77 | 0.89 | 1.05 | 1.59 | 2.71 | | master Ractor | 0.71 | 0.99 | 1.68 | 3.87 | 14.59 | | RLGC fork | 0.80 | 0.85 | 0.98 | 1.87 | 3.02 | | master fork | 0.74 | 0.80 | 0.97 | 1.83 | 3.04 | ratio (x master-Ractor N=1): | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 1.08 | 1.25 | 1.48 | 2.25 | **3.82** | | master Ractor | 1.00 | 1.40 | 2.37 | 5.45 | 20.55 | | RLGC fork | 1.12 | 1.19 | 1.39 | 2.63 | 4.25 | | master fork | 1.04 | 1.12 | 1.37 | 2.58 | 4.29 | At N=16 RLGC's `move` beats even process IPC (2.71s vs 3.02-3.04s: no kernel copy), while master Ractors are 5.4x behind it. The off-heap courier costs ~8% over master's in-heap move at N=1 (0.77 vs 0.71); that is the price of making in-flight messages invisible to both sides' GCs, and a zero-copy fast path remains possible future work. ### 4.4 Single-Ractor performance Measured with no `Ractor.new` at all (the N=1 rows above already run one worker Ractor, which puts the process in multi-Ractor mode). The allocation fast path itself got faster: the per-Ractor newobj cache is gone and objects come straight off the objspace's bump-pointer heaps. | single Ractor, interleaved minima | RLGC | master | | |---------------------------------------|------|--------|------| | `Object.new` x 20M, GC disabled | 1.83 | 2.01 | **-9%** | | `Object.new` x 20M, GC enabled | 1.18 | 1.10 | +7% | | binary-trees (D=16) | 3.79 | 3.71 | +2% | | JSON parse (8000 docs) | 1.55 | 1.49 | +4% | (`Object.new` rows subtract the loop-only baseline of 0.24s.) Pure allocation wins by 9%; sweep-time bitmap maintenance (the shareable and shref bitmaps) gives ~7% back under allocate-and-discard churn, which nets out to +2-4% on GC-heavy end-to-end workloads and less on anything that does real work between allocations. The multi-objspace machinery is gated behind a single-objspace check on hot paths. ### 4.5 Latency Beyond throughput, the qualitative change is who pauses: on master every GC of any Ractor stops all of them, so one allocation-heavy Ractor imposes its GC pauses on every other Ractor in the process. With per-Ractor GC a Ractor pauses only for its own collections; the remaining process-wide pauses are explicit `GC.start`, shareable-population growth and dead-Ractor cleanup, all of which are rare in steady state. ## 5. Limitations and future work * Incremental marking and non-stop-the-world compaction currently run only while the process has a single objspace. Per-objspace incremental marking is possible in principle (the machinery is per objspace already) but needs the single-to-multi transition handled mid-cycle. * `move:` uses an off-heap serialization; a zero-copy fast path (moving whole pages for large graphs) is future work, as is extending the FREEOBJ tracepoint beyond the main Ractor. * A global GC is still fully stop-the-world; making it concurrent or incremental is orthogonal future work. ## 6. Validation `make btest` (2054 tests) and `make test-all` are green on x86_64-linux across release, `RUBY_DEBUG=1`, ASAN, TSAN and MMTk builds. `GC.verify_internal_consistency` includes the containment verifier (`RGENGC_CHECK_MODE` builds run it around every collection); it was used throughout development. The branch adds ~90 Ractor/GC tests covering lifecycle, copy/move semantics, compaction interaction, and regressions for every bug found during a multi-week stress campaign (a corpus of ~1000 generated Ractor programs run under GC.stress, ASAN and TSAN). ## Notes * This work completes the design presented in [Toward Ractor local GC (RubyKaigi 2025)](https://rubykaigi.org/2025/presentations/ko1.html). * Much of this description, and a large part of the code, was written with Claude Code (Fable/Opus). * Acknowledgements * This work started as a collaboration with Rohit Menon. * Thanks to the Shopify Ractor team for their feedback. * And thank you, everyone, for waiting so long for this work. -- https://bugs.ruby-lang.org/
Issue #22227 has been updated by Eregon (Benoit Daloze). ko1 (Koichi Sasada) wrote:
**Never free a shareable object.** At the end of marking (`gc_marks_finish`), a dedicated pass (`pinned_roots_mark`) marks every shareable object on the objspace's pages that ordinary marking did not reach: mark bit only, no traversal.
Is this safe? It's writing concurrently to a shareable object IIUC, and might leave leftover mark bits which might matter for the global GC. ---------------------------------------- Feature #22227: Per-Ractor GC: collect each Ractor's heap locally, stop the world only when needed https://bugs.ruby-lang.org/issues/22227#change-118368 * Author: ko1 (Koichi Sasada) * Status: Open ---------------------------------------- Companion PR: https://github.com/ruby/ruby/pull/18194 --- ## Abstract This proposal makes the default GC operate per Ractor. Each Ractor owns an objspace (a private heap) and collects it on its own thread without stopping any other Ractor ("local GC"). A stop-the-world collection over all objspaces ("global GC") runs only when something genuinely global has to be reclaimed: shareable objects, memory retained across Ractor boundaries, and the heaps of dead Ractors. With this change, allocation-heavy Ractor programs scale like forked processes, and Ractor communication can even beat interprocess communication; end-to-end single-Ractor workloads stay within 2-4% of current performance (focused allocation microbenchmarks move a few percent in both directions, see section 4.4). ## 1. Motivation Today all Ractors allocate from one shared objspace, and every GC stops every Ractor. This has two consequences: 1. GC pause time grows with the total heap of all Ractors, while the pause still stops everyone. N allocating Ractors trigger GCs N times as often, and each GC scans N heaps. 2. The synchronization needed to stop N mutators at every GC dominates as N grows. In practice, a program that parses JSON in 16 Ractors spends 10x the wall clock of its single-Ractor version (for the same per-worker work), while the identical code in 16 forked processes stays at the hardware's parallel limit of about 3.3x. Ractors currently lose to fork exactly on the workloads they were designed for. The root cause is architectural, not incremental: CRuby's current shared objspace couples every Ractor to the same stop-the-world GC. This proposal splits the heap. ## 2. Design overview Terminology used below: * **objspace**: one Ractor's private heap: its pages, freelists, mark stack, generational state and counters. * **local GC**: a collection of a single objspace, run by its owner thread, with no VM lock and no barrier. Other Ractors keep running. * **global GC**: a stop-the-world mark/sweep over all objspaces as one heap. Heap traversal is exact; machine-stack roots remain conservative. * **shareable object**: `Ractor.shareable?(obj) == true` (immutable values, Class/Module, shareable Procs, and so on). * **shref** ("shareable reference"): an unshareable object directly referenced by a shareable one. Details are given in 2.1 and 2.3. ### 2.1 The containment invariant The correctness of a local GC rests on one invariant:
An unshareable object is reachable only from its owner Ractor, except through edges that the GC has been explicitly told about (shrefs and in-flight messages).
The invariant has an equally important flip side: a **shareable object can be referenced from any Ractor at any time, and nothing records who references it**. A local GC therefore never collects a shareable object, and never even traverses one; it only marks them all as live (2.3). Only a global GC, whose unified mark sees every Ractor's references, can prove a shareable object dead and reclaim it. In other words: unshareable objects are the local GC's responsibility, shareable objects are exclusively the global GC's. Ruby-level Ractor semantics already forbid directly sharing unshareable objects. CRuby, however, has a small number of implementation-level edges that cross the boundary, and the GC work is to make every one of them visible to the collector: * A shareable object can hold an implementation-level reference to an unshareable one: for example, writing through a shareable Proc's environment stores an unshareable value into a shareable env, and a shareable object's hidden instance variables (its fields imemo) can stay unshareable. The **write barrier** detects every such store and records the target as a **shref**. The transition that makes an object shareable likewise registers any pre-existing outgoing edges of this kind (for example, the hidden field values that `Ractor.make_shareable`'s traversal does not reach are shref-recorded at promotion time). * A message in flight (sent, not yet received) lives in the sender's objspace while being reachable only from queue structures. The send path **pins** the payload in the sender's objspace until the receiver materializes it (2.6). Given the invariant, marking one objspace's roots reaches everything a local GC is allowed to free, so a local GC requires no process-wide barrier and never stops another Ractor; its synchronization is confined to leaf mutexes and atomic bitmap operations on shared metadata (2.3). `GC.verify_internal_consistency` walks the heap and checks the invariant edge by edge (builds with `RGENGC_CHECK_MODE` also run it around every collection); it caught most of the bugs found during development. ### 2.2 Heap layout and ownership Every heap page records its owning objspace, so any object's owner is one load away: `GET_HEAP_OBJSPACE(obj)` reads the page header. There is no per-object tag; ownership is a property of the page. Page bodies come from a process-wide **page pool**: large mmap arenas carved into page-sized bodies and recycled through a freelist under a dedicated mutex. Ractors therefore exchange pages (allocation, death, inheritance) without mmap/munmap churn, and without serializing on the kernel's mmap lock. A process-wide **page index** (all pages of all objspaces, sorted by body address) supports the conservative machine-stack scan of a global GC and ownership tests; only stop-the-world code reads it, so it needs no reader lock. Allocation is a bump-pointer fast path (a cursor advancing through free regions) into per-objspace, per-size-class heaps. The upstream per-Ractor newobj cache became unnecessary (the objspace itself is per Ractor), which makes the allocation fast path about 9% faster. ### 2.3 What a local GC does A local GC is the classic incremental/generational collector of CRuby, restricted to one objspace, plus three rules: 1. **Never free a shareable object.** At the end of marking (`gc_marks_finish`), a dedicated pass (`pinned_roots_mark`) marks every shareable object on the objspace's pages that ordinary marking did not reach: mark bit only, no traversal. Another Ractor may hold the only reference to it; only a global GC can prove a shareable object dead. The count of objects kept alive this way is also a useful retention metric (an upper bound on garbage only a global GC can reclaim). 2. **Treat shrefs as roots.** A shref is marked and traversed like a remembered old-to-young target: the shareable parent is never traversed by a local GC (rule 1), so without this its unshareable children would look unreachable. 3. **Never touch a foreign object's GC state.** Marking stops at any object owned by another objspace ("foreign objects are live leaves"): its mark/age/remembered bits belong to its owner, and writing them would race the owner's local GC. Because objects can become shareable at any time between collections, rule 1 scans the shareable/shref bitmaps in every mark cycle rather than trying to maintain a pin set across sweeps. Generational collection stays per objspace. The remembered-set bitmap has exactly one class of cross-thread writers (another Ractor's write barrier remembering a shareable object), and that path uses an atomic bitmap set; everything else is single-writer under the owner's GVL. **Locking model.** A local GC takes neither the VM lock nor any barrier; it never stops or waits for another Ractor. The main Ractor's local GC is the one exception in two bounded windows: it walks VM-global roots and weak tables (`rb_vm_mark`, freeing entries of fstring/symbol/ci tables), which other Ractors rewrite under the VM lock, so it takes a no-barrier VM lock just for those stretches; and when a JIT is enabled, marking reaches JIT payloads of shareable iseqs, which must exclude concurrent compilation. One rule is absolute: a GC must never take the barrier VM lock mid-collection, because the waiter would join a pending global barrier and expose its half-collected heap to the global GC. Shared structures that GC paths touch (page pool, the generic-fields table, registered globals) use their own leaf mutexes instead. **Triggers.** Every local GC entry first asks whether a *global* cycle is needed instead (see 2.4); otherwise the usual malloc/allocation heuristics drive local minor/major cycles per objspace. ### 2.4 What a global GC does A global GC stops the world using the existing barrier mechanism (a GC has no safepoints, so the barrier implicitly waits for every in-flight local GC to finish). Then, with the driver thread executing every phase: 1. **Settle**: finish every objspace's lazy sweep, so mark bits have a single meaning. 2. **Clear**: clear every objspace's mark bits, remembered sets, age metadata and all shref bits. (Missing any of these would leave stale bits and cause use-after-free; this step is why shref bits may only be cleared by a global GC.) 3. **Unified mark**: mark from every Ractor's roots (C-struct roots, machine stacks scanned conservatively against the global page index, VM globals) and traverse exactly, crossing objspace boundaries freely. The write barrier's shref information is re-derived as a byproduct: each shareable-to-unshareable edge encountered re-records the shref bit. In-flight message payloads are re-pinned here (their shref bits were cleared in step 2). 4. **Weak passes**: the generic-fields table (weak keys) is processed to a fixpoint: values (`fields_obj`) of live keys are marked, entries of dead keys are dropped, and since marking a value can make another key live, the pass repeats until no progress. VM-global weak tables (fstring, symbols, call caches and so on) are swept once, not once per objspace. 5. **Sweep**: sweep every objspace inside the barrier (not lazily). This is the only place shareable objects and cross-objspace cycles die. Emptied pages return to the page pool. 6. **Budget**: objspaces left with no free pages get the same heap-growth budget a local full mark would have granted (a global GC bypasses that bookkeeping), so the next allocation does not fail. 7. Shareable-object populations and trigger limits are recounted per objspace. Global GCs are triggered by: an explicit full `GC.start`; growth of the shareable-object population past a per-objspace limit; and accumulation of heap pages held by dead Ractors (see 2.5). Two Ractors deciding to run a global GC at the same time are serialized by the barrier; the second cycle is wasted work, not an error. **Compaction.** With multiple objspaces, `GC.compact` (and autocompact on full marks) runs inside a global GC as three passes over all objspaces: (a) move every objspace's movable objects, leaving forwarding; (b) update references everywhere, following forwarding across objspaces, plus the VM-global side once; (c) sweep and free emptied source pages. It must be three global passes rather than per-objspace work because a reference can point at a moved object in another objspace, and freeing a source page early would let another objspace's update read freed forwarding. A local (non-stop-the-world) compaction runs only while the process has a single objspace; objects that C structs of other Ractors point at (an in-flight payload, a terminated Ractor's pending value) are pinned so they never move even under global compaction. Incremental marking similarly runs only in single-objspace mode; the transition point (`Ractor.new` making the process multi-Ractor) settles any cycle in progress. ### 2.5 Ractor lifecycle and memory hand-off **Creation.** A Ractor's objspace is created before its thread starts, and the child's main Thread and root Fiber wrapper objects are allocated directly into it. Between those allocations and the child joining the Ractor set, the creator "covers" the child objspace through a dedicated slot so a concurrent global GC still enumerates it (an unenumerated objspace would keep stale mark bits). Every failure path of creation (isolation errors, thread-creation failure) hands the child objspace over for reclamation rather than leaking it. **Termination.** A terminating Ractor runs one last local GC with a by-then minimal root set on its own thread ("retire GC": roots are minimal by then, and this reclaims garbage that would otherwise be inherited), and its objspace becomes a **zombie**: registered in a VM-wide table, mutated by nobody, but enumerated by every global GC. Ractor death therefore does not stop the world. **Inheritance.** `Ractor#value` (and join) *absorbs* the dead Ractor's objspace into the caller's: pages are spliced wholesale into the caller's heaps (kept sorted by body address for the conservative scan), the finalizer table entries move over, malloc-accounting counters transfer, and the caller's next collection is forced to a full mark to rebuild generational state. This is why `Ractor#value` can return the value **by reference** with no copy: after the absorb it is an ordinary object of the caller. If a dead Ractor's object is itself collected without ever being joined, its orphan objspace is merged into the main Ractor at main's next safepoint (scheduled as a postponed job, because the discovery happens inside a sweep). At VM shutdown all remaining zombies merge into main so at-exit processing sees every object. Because a zombie's pages still hold shareable objects that other Ractors can reach, zombies participate in the global-GC trigger: accumulated zombie pages past a threshold start a global cycle (measured against what survived the previous one, so a zombie with much live data does not retrigger forever). ### 2.6 Message passing: copy `Ractor#send` (copy semantics) became a two-phase protocol: 1. **Snapshot (sender side).** The sender deep-copies the object graph inside its own objspace using a native copier for core types (String, Array, Hash, Struct, T_OBJECT and so on); types the native copier does not support fall back to a Marshal round trip. `#clone` and `initialize_copy` are never invoked, although Marshal-specific hooks (`marshal_dump`, `_dump`) run on the sender for fallback types and can raise there. Every node of the snapshot is **pinned** in the sender's objspace, and the pin cover is handed off without gaps: from the builder's capture list, to the basket (queue entry), to the receiver's materialization frame. A global GC re-pins from whichever holder is current (its clear pass drops all shref bits, see 2.4 step 2/3). 2. **Materialize (receiver side).** When the receiver takes the message out of the port, it rebuilds the graph inside its own objspace. The sender-resident snapshot becomes garbage once the copy completes. Materialization can raise (Marshal load hooks and autoload run user code; asynchronous interrupts can arrive) and can nest (`Ractor.receive` inside a load hook), so in-progress materializations form a per-EC chain of machine-stack frames that the GC uses as roots. Passing the sender-resident graph to the receiver by reference is never allowed: it would create an unshareable cross-objspace edge that neither local GC could handle. ### 2.7 Message passing: move `Ractor#send(obj, move: true)` serializes the payload graph into an off-heap, malloc-allocated structure called the **courier**. While a moved graph is in flight, there is *no heap object* representing it: neither side's GC can mark, sweep, move, or race with it. * A read-only **preflight** walks the graph first and raises every "can not move" error before anything is mutated (capture is destructive, so failing halfway would corrupt the graph beyond repair). * **Capture** turns each source object into a node in the courier's node array (identity and cycles are preserved through node ids and a by-address dedup table) and immediately neutralizes the source into a `Ractor::MovedObject` shell, without ever passing through an inconsistent state that a concurrent marker could observe. * Payload internals move by ownership transfer where possible: a String that owns its buffer hands the pointer over (zero copy); an IO carries its whole `rb_io_t` including the file descriptor; MatchData dumps its match registers into an onig-independent blob. Shareable references and immediates are carried as values; classes of the moved objects are carried as references (classes are shareable), so subclasses and singleton classes survive the move. * In-flight couriers are registered in a VM-global list; a global GC's root pass marks and pins the shareable references they carry (nothing else roots them in some windows). * **Rebuild** on the receiver allocates all shells first, then fills references (two passes break cycles), and inserts Hash entries last, in reverse id order, so user-defined `#hash` methods observe fully built keys. Rebuilding can raise; an unconsumed courier is freed by the queue teardown, closing descriptor leaks for moved IOs. ### 2.8 Generic instance variables Instance variables of non-T_OBJECT hosts (`str.instance_variable_set` and friends) live in one process-global table keyed weakly by host object, guarded by a dedicated leaf mutex. A local GC reads it per object while marking (it cannot wait for the VM lock there); the global GC processes it in the weak pass described above; per-object cleanup happens when a host is freed. An earlier design with per-Ractor tables plus a shared table was abandoned: entries would have had to migrate on every make-shareable, send and inheritance, and that boundary was a reliable source of bugs. ### 2.9 Modular GC API The GC implementation interface (`gc/gc_impl.h`) gains per-Ractor entry points: `multi_objspace_p`, `objspace_absorb`, `objspace_retire_gc`, `obj_foreign_p`, `during_global_gc_p`, `obj_became_shareable`, `pin_in_flight_message`, `each_objects_shareable`, `each_objects_foreign`, `heap_page_count`, `gc_rest`. An implementation that keeps a single objspace (MMTk) reports `multi_objspace_p() == false` and the VM gates the whole per-Ractor machinery out: every Ractor aliases the one objspace, retire/absorb/zombie handling become no-ops, and Ractor-related marking falls back to following everything from the wrapper objects. MMTk continues to pass its test suite unchanged. ## 3. Compatibility * `Ractor#value` becomes a one-shot ownership transfer: the first call hands the value over by reference together with the dead Ractor's heap, and a second call raises `Ractor::Error` ("The value was already taken"). Returning the same unshareable object again would hand out a reference to an object now owned by the first caller, recreating exactly the cross-Ractor sharing that Ractor semantics forbid; special-casing repeat calls from the same Ractor, or shareable values only, would make the API's behavior depend on the caller and the value's type, so the simple rule was chosen. (Previously repeated calls returned the same object.) * `GC.start` in a multi-Ractor process runs a stop-the-world global GC; automatic GC in a Ractor collects only its own heap. * `GC.disable`/`GC.enable` become per-Ractor holds on a process-wide switch: any Ractor's disable stops automatic GC everywhere, but another Ractor's `GC.enable` no longer silently overrides it. A Ractor's hold is released when it exits. * `ObjectSpace.each_object` enumerates the calling Ractor's own objects plus other live Ractors' shareable objects, never their unshareable ones. Objects of a terminated but not yet inherited Ractor are not enumerated until its heap is absorbed. (`ObjectSpace.dump_all` and `memsize_of_all` still cover every object in the process; they walk the other objspaces under a global barrier.) * `ObjectSpace.define_finalizer` on another Ractor's object raises `Ractor::IsolationError`; finalizers are registered, stored and run in the owner's objspace. * The `RUBY_INTERNAL_EVENT_FREEOBJ` tracepoint fires only for the main Ractor's objspace (it runs user callbacks from inside the sweep, which is unsafe in a lock-free non-main sweep; extending it is future work). * Invalidated call-cache entries are no longer pruned during GC marking; they are reclaimed at the next lookup of that method or when the class dies. Retention is bounded (one entry per class-and-method-id pair); the worst-case microbenchmark retains approximately 2.7 MB for 200,000 stale entries. ## 4. Performance Setup: 16 logical CPUs (8 cores, SMT), x86_64-linux, `-O3` release builds. `master` is the merge-base of the PR, `RLGC` is the branch. Every cell is wall-clock seconds until all N workers finish (lower is better). Because the machine's clock state drifts on multi-minute scales, each RLGC/master pair was measured interleaved back to back, and each number is the minimum over at least 10 runs spread across several sessions. All three programs are weak-scaling benchmarks: every worker runs the full workload independently, so with perfect parallelism the wall clock stays flat as N grows. The ratio tables divide every cell by master-Ractor N=1, i.e. "how many times the wall time of current Ruby running one Ractor"; a perfectly scaling configuration keeps its N=1 ratio at every N. "Ractor" rows run the N workers as Ractors, "fork" rows run the identical code in forked processes: the no-shared-GC baseline that Ractors should match. Scripts are about 40 lines each and attached to the PR. ### 4.1 binary-trees (D=16; allocation/GC-heavy) The classic benchmarks-game workload: each worker builds and checksums millions of short-lived binary trees (3-element arrays). Allocation and GC dominate; objects die young. seconds: | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 3.83 | 4.01 | 4.48 | 5.76 | 10.26 | | master Ractor | 3.65 | 4.18 | 5.32 | 8.18 | 14.90 | | RLGC fork | 3.75 | 3.99 | 4.48 | 6.56 | 10.22 | | master fork | 3.66 | 3.90 | 4.40 | 6.40 | 10.03 | ratio (x master-Ractor N=1): | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 1.05 | 1.10 | 1.23 | 1.58 | **2.81** | | master Ractor | 1.00 | 1.15 | 1.46 | 2.24 | 4.08 | | RLGC fork | 1.03 | 1.09 | 1.23 | 1.80 | 2.80 | | master fork | 1.00 | 1.07 | 1.21 | 1.75 | 2.75 | RLGC Ractors scale exactly like forked processes (2.81 vs 2.75-2.80 at N=16); master Ractors are 1.45x behind at N=16. For calibration, a zero-allocation pure-integer loop on this machine goes from 5.9s (N=1) to 10.4s (N=16), so RLGC's 10.26s at N=16 is the hardware's SMT ceiling, not a GC limit. ### 4.2 JSON parse (embarrassingly parallel) Each worker parses its own copy of the same ~30 KB JSON document (200 nested user records) 8000 times and discards each result: pure parse-allocate-discard churn, no data crosses workers. This isolates how allocation and GC scale. seconds: | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 1.59 | 1.70 | 1.89 | 2.61 | 4.93 | | master Ractor | 1.49 | 2.06 | 2.63 | 5.24 | 15.10 | | RLGC fork | 1.50 | 1.73 | 1.88 | 2.53 | 4.87 | | master fork | 1.48 | 1.64 | 1.81 | 2.48 | 4.94 | ratio (x master-Ractor N=1): | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 1.07 | 1.14 | 1.27 | 1.76 | **3.32** | | master Ractor | 1.00 | 1.39 | 1.77 | 3.53 | 10.17 | | RLGC fork | 1.01 | 1.17 | 1.27 | 1.71 | 3.28 | | master fork | 0.99 | 1.10 | 1.22 | 1.67 | 3.33 | master Ractors fall off almost immediately (1.39x already at N=2: every Ractor's allocation churn stops all the others) and end 10x over the baseline at N=16; RLGC matches the fork rows within noise the whole way and finishes 3.1x faster than master Ractors. ### 4.3 JSON producer/consumer (Ractor move vs process IPC) The "coordinator hands work to parsers" pattern: the main Ractor/process produces 20000xN JSON strings (~6 KB each) and distributes them round-robin to N workers, which parse and discard them. The Ractor version transfers each string with `send(move: true)`; the fork version writes it down a pipe (a kernel copy). This measures the data-transfer path, not just parallel GC. seconds: | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 0.77 | 0.89 | 1.05 | 1.59 | 2.71 | | master Ractor | 0.71 | 0.99 | 1.68 | 3.87 | 14.59 | | RLGC fork | 0.80 | 0.85 | 0.98 | 1.87 | 3.02 | | master fork | 0.74 | 0.80 | 0.97 | 1.83 | 3.04 | ratio (x master-Ractor N=1): | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 1.08 | 1.25 | 1.48 | 2.25 | **3.82** | | master Ractor | 1.00 | 1.40 | 2.37 | 5.45 | 20.55 | | RLGC fork | 1.12 | 1.19 | 1.39 | 2.63 | 4.25 | | master fork | 1.04 | 1.12 | 1.37 | 2.58 | 4.29 | At N=16 RLGC's `move` beats even process IPC (2.71s vs 3.02-3.04s: no kernel copy), while master Ractors are 5.4x behind it. The off-heap courier costs ~8% over master's in-heap move at N=1 (0.77 vs 0.71); that is the price of making in-flight messages invisible to both sides' GCs, and a zero-copy fast path remains possible future work. ### 4.4 Single-Ractor performance Measured with no `Ractor.new` at all (the N=1 rows above already run one worker Ractor, which puts the process in multi-Ractor mode). The allocation fast path itself got faster: the per-Ractor newobj cache is gone and objects come straight off the objspace's bump-pointer heaps. | single Ractor, interleaved minima | RLGC | master | | |---------------------------------------|------|--------|------| | `Object.new` x 20M, GC disabled | 1.83 | 2.01 | **-9%** | | `Object.new` x 20M, GC enabled | 1.18 | 1.10 | +7% | | binary-trees (D=16) | 3.79 | 3.71 | +2% | | JSON parse (8000 docs) | 1.55 | 1.49 | +4% | (`Object.new` rows subtract the loop-only baseline of 0.24s.) Pure allocation wins by 9%; sweep-time bitmap maintenance (the shareable and shref bitmaps) gives ~7% back under allocate-and-discard churn, which nets out to +2-4% on GC-heavy end-to-end workloads and less on anything that does real work between allocations. The multi-objspace machinery is gated behind a single-objspace check on hot paths. ### 4.5 Latency Beyond throughput, the qualitative change is who pauses: on master every GC of any Ractor stops all of them, so one allocation-heavy Ractor imposes its GC pauses on every other Ractor in the process. With per-Ractor GC a Ractor pauses only for its own collections; the remaining process-wide pauses are explicit `GC.start`, shareable-population growth and dead-Ractor cleanup, all of which are rare in steady state. ## 5. Limitations and future work * Incremental marking and non-stop-the-world compaction currently run only while the process has a single objspace. Per-objspace incremental marking is possible in principle (the machinery is per objspace already) but needs the single-to-multi transition handled mid-cycle. * `move:` uses an off-heap serialization; a zero-copy fast path (moving whole pages for large graphs) is future work, as is extending the FREEOBJ tracepoint beyond the main Ractor. * A global GC is still fully stop-the-world; making it concurrent or incremental is orthogonal future work. ## 6. Validation `make btest` (2054 tests) and `make test-all` are green on x86_64-linux across release, `RUBY_DEBUG=1`, ASAN, TSAN and MMTk builds. `GC.verify_internal_consistency` includes the containment verifier (`RGENGC_CHECK_MODE` builds run it around every collection); it was used throughout development. The branch adds ~90 Ractor/GC tests covering lifecycle, copy/move semantics, compaction interaction, and regressions for every bug found during a multi-week stress campaign (a corpus of ~1000 generated Ractor programs run under GC.stress, ASAN and TSAN). ## Notes * This work completes the design presented in [Toward Ractor local GC (RubyKaigi 2025)](https://rubykaigi.org/2025/presentations/ko1.html). * Much of this description, and a large part of the code, was written with Claude Code (Fable/Opus). * Acknowledgements * This work started as a collaboration with Rohit Menon. * Thanks to the Shopify Ractor team for their feedback. * And thank you, everyone, for waiting so long for this work. -- https://bugs.ruby-lang.org/
Issue #22227 has been updated by luke-gru (Luke Gruber). Eregon (Benoit Daloze) wrote in #note-2:
Is this safe? It's writing concurrently to a shareable object IIUC, and might leave leftover mark bits which might matter for the global GC.
It only marks shareable objects that are in the creating Ractor's objspace (allocated by that Ractor). The marking is done in a bitmap on the page. Shareable objects in foreign objspaces get marked by their local GC and aren't traversed or marked during local GC. ---------------------------------------- Feature #22227: Per-Ractor GC: collect each Ractor's heap locally, stop the world only when needed https://bugs.ruby-lang.org/issues/22227#change-118369 * Author: ko1 (Koichi Sasada) * Status: Open ---------------------------------------- Companion PR: https://github.com/ruby/ruby/pull/18194 --- ## Abstract This proposal makes the default GC operate per Ractor. Each Ractor owns an objspace (a private heap) and collects it on its own thread without stopping any other Ractor ("local GC"). A stop-the-world collection over all objspaces ("global GC") runs only when something genuinely global has to be reclaimed: shareable objects, memory retained across Ractor boundaries, and the heaps of dead Ractors. With this change, allocation-heavy Ractor programs scale like forked processes, and Ractor communication can even beat interprocess communication; end-to-end single-Ractor workloads stay within 2-4% of current performance (focused allocation microbenchmarks move a few percent in both directions, see section 4.4). ## 1. Motivation Today all Ractors allocate from one shared objspace, and every GC stops every Ractor. This has two consequences: 1. GC pause time grows with the total heap of all Ractors, while the pause still stops everyone. N allocating Ractors trigger GCs N times as often, and each GC scans N heaps. 2. The synchronization needed to stop N mutators at every GC dominates as N grows. In practice, a program that parses JSON in 16 Ractors spends 10x the wall clock of its single-Ractor version (for the same per-worker work), while the identical code in 16 forked processes stays at the hardware's parallel limit of about 3.3x. Ractors currently lose to fork exactly on the workloads they were designed for. The root cause is architectural, not incremental: CRuby's current shared objspace couples every Ractor to the same stop-the-world GC. This proposal splits the heap. ## 2. Design overview Terminology used below: * **objspace**: one Ractor's private heap: its pages, freelists, mark stack, generational state and counters. * **local GC**: a collection of a single objspace, run by its owner thread, with no VM lock and no barrier. Other Ractors keep running. * **global GC**: a stop-the-world mark/sweep over all objspaces as one heap. Heap traversal is exact; machine-stack roots remain conservative. * **shareable object**: `Ractor.shareable?(obj) == true` (immutable values, Class/Module, shareable Procs, and so on). * **shref** ("shareable reference"): an unshareable object directly referenced by a shareable one. Details are given in 2.1 and 2.3. ### 2.1 The containment invariant The correctness of a local GC rests on one invariant:
An unshareable object is reachable only from its owner Ractor, except through edges that the GC has been explicitly told about (shrefs and in-flight messages).
The invariant has an equally important flip side: a **shareable object can be referenced from any Ractor at any time, and nothing records who references it**. A local GC therefore never collects a shareable object, and never even traverses one; it only marks them all as live (2.3). Only a global GC, whose unified mark sees every Ractor's references, can prove a shareable object dead and reclaim it. In other words: unshareable objects are the local GC's responsibility, shareable objects are exclusively the global GC's. Ruby-level Ractor semantics already forbid directly sharing unshareable objects. CRuby, however, has a small number of implementation-level edges that cross the boundary, and the GC work is to make every one of them visible to the collector: * A shareable object can hold an implementation-level reference to an unshareable one: for example, writing through a shareable Proc's environment stores an unshareable value into a shareable env, and a shareable object's hidden instance variables (its fields imemo) can stay unshareable. The **write barrier** detects every such store and records the target as a **shref**. The transition that makes an object shareable likewise registers any pre-existing outgoing edges of this kind (for example, the hidden field values that `Ractor.make_shareable`'s traversal does not reach are shref-recorded at promotion time). * A message in flight (sent, not yet received) lives in the sender's objspace while being reachable only from queue structures. The send path **pins** the payload in the sender's objspace until the receiver materializes it (2.6). Given the invariant, marking one objspace's roots reaches everything a local GC is allowed to free, so a local GC requires no process-wide barrier and never stops another Ractor; its synchronization is confined to leaf mutexes and atomic bitmap operations on shared metadata (2.3). `GC.verify_internal_consistency` walks the heap and checks the invariant edge by edge (builds with `RGENGC_CHECK_MODE` also run it around every collection); it caught most of the bugs found during development. ### 2.2 Heap layout and ownership Every heap page records its owning objspace, so any object's owner is one load away: `GET_HEAP_OBJSPACE(obj)` reads the page header. There is no per-object tag; ownership is a property of the page. Page bodies come from a process-wide **page pool**: large mmap arenas carved into page-sized bodies and recycled through a freelist under a dedicated mutex. Ractors therefore exchange pages (allocation, death, inheritance) without mmap/munmap churn, and without serializing on the kernel's mmap lock. A process-wide **page index** (all pages of all objspaces, sorted by body address) supports the conservative machine-stack scan of a global GC and ownership tests; only stop-the-world code reads it, so it needs no reader lock. Allocation is a bump-pointer fast path (a cursor advancing through free regions) into per-objspace, per-size-class heaps. The upstream per-Ractor newobj cache became unnecessary (the objspace itself is per Ractor), which makes the allocation fast path about 9% faster. ### 2.3 What a local GC does A local GC is the classic incremental/generational collector of CRuby, restricted to one objspace, plus three rules: 1. **Never free a shareable object.** At the end of marking (`gc_marks_finish`), a dedicated pass (`pinned_roots_mark`) marks every shareable object on the objspace's pages that ordinary marking did not reach: mark bit only, no traversal. Another Ractor may hold the only reference to it; only a global GC can prove a shareable object dead. The count of objects kept alive this way is also a useful retention metric (an upper bound on garbage only a global GC can reclaim). 2. **Treat shrefs as roots.** A shref is marked and traversed like a remembered old-to-young target: the shareable parent is never traversed by a local GC (rule 1), so without this its unshareable children would look unreachable. 3. **Never touch a foreign object's GC state.** Marking stops at any object owned by another objspace ("foreign objects are live leaves"): its mark/age/remembered bits belong to its owner, and writing them would race the owner's local GC. Because objects can become shareable at any time between collections, rule 1 scans the shareable/shref bitmaps in every mark cycle rather than trying to maintain a pin set across sweeps. Generational collection stays per objspace. The remembered-set bitmap has exactly one class of cross-thread writers (another Ractor's write barrier remembering a shareable object), and that path uses an atomic bitmap set; everything else is single-writer under the owner's GVL. **Locking model.** A local GC takes neither the VM lock nor any barrier; it never stops or waits for another Ractor. The main Ractor's local GC is the one exception in two bounded windows: it walks VM-global roots and weak tables (`rb_vm_mark`, freeing entries of fstring/symbol/ci tables), which other Ractors rewrite under the VM lock, so it takes a no-barrier VM lock just for those stretches; and when a JIT is enabled, marking reaches JIT payloads of shareable iseqs, which must exclude concurrent compilation. One rule is absolute: a GC must never take the barrier VM lock mid-collection, because the waiter would join a pending global barrier and expose its half-collected heap to the global GC. Shared structures that GC paths touch (page pool, the generic-fields table, registered globals) use their own leaf mutexes instead. **Triggers.** Every local GC entry first asks whether a *global* cycle is needed instead (see 2.4); otherwise the usual malloc/allocation heuristics drive local minor/major cycles per objspace. ### 2.4 What a global GC does A global GC stops the world using the existing barrier mechanism (a GC has no safepoints, so the barrier implicitly waits for every in-flight local GC to finish). Then, with the driver thread executing every phase: 1. **Settle**: finish every objspace's lazy sweep, so mark bits have a single meaning. 2. **Clear**: clear every objspace's mark bits, remembered sets, age metadata and all shref bits. (Missing any of these would leave stale bits and cause use-after-free; this step is why shref bits may only be cleared by a global GC.) 3. **Unified mark**: mark from every Ractor's roots (C-struct roots, machine stacks scanned conservatively against the global page index, VM globals) and traverse exactly, crossing objspace boundaries freely. The write barrier's shref information is re-derived as a byproduct: each shareable-to-unshareable edge encountered re-records the shref bit. In-flight message payloads are re-pinned here (their shref bits were cleared in step 2). 4. **Weak passes**: the generic-fields table (weak keys) is processed to a fixpoint: values (`fields_obj`) of live keys are marked, entries of dead keys are dropped, and since marking a value can make another key live, the pass repeats until no progress. VM-global weak tables (fstring, symbols, call caches and so on) are swept once, not once per objspace. 5. **Sweep**: sweep every objspace inside the barrier (not lazily). This is the only place shareable objects and cross-objspace cycles die. Emptied pages return to the page pool. 6. **Budget**: objspaces left with no free pages get the same heap-growth budget a local full mark would have granted (a global GC bypasses that bookkeeping), so the next allocation does not fail. 7. Shareable-object populations and trigger limits are recounted per objspace. Global GCs are triggered by: an explicit full `GC.start`; growth of the shareable-object population past a per-objspace limit; and accumulation of heap pages held by dead Ractors (see 2.5). Two Ractors deciding to run a global GC at the same time are serialized by the barrier; the second cycle is wasted work, not an error. **Compaction.** With multiple objspaces, `GC.compact` (and autocompact on full marks) runs inside a global GC as three passes over all objspaces: (a) move every objspace's movable objects, leaving forwarding; (b) update references everywhere, following forwarding across objspaces, plus the VM-global side once; (c) sweep and free emptied source pages. It must be three global passes rather than per-objspace work because a reference can point at a moved object in another objspace, and freeing a source page early would let another objspace's update read freed forwarding. A local (non-stop-the-world) compaction runs only while the process has a single objspace; objects that C structs of other Ractors point at (an in-flight payload, a terminated Ractor's pending value) are pinned so they never move even under global compaction. Incremental marking similarly runs only in single-objspace mode; the transition point (`Ractor.new` making the process multi-Ractor) settles any cycle in progress. ### 2.5 Ractor lifecycle and memory hand-off **Creation.** A Ractor's objspace is created before its thread starts, and the child's main Thread and root Fiber wrapper objects are allocated directly into it. Between those allocations and the child joining the Ractor set, the creator "covers" the child objspace through a dedicated slot so a concurrent global GC still enumerates it (an unenumerated objspace would keep stale mark bits). Every failure path of creation (isolation errors, thread-creation failure) hands the child objspace over for reclamation rather than leaking it. **Termination.** A terminating Ractor runs one last local GC with a by-then minimal root set on its own thread ("retire GC": roots are minimal by then, and this reclaims garbage that would otherwise be inherited), and its objspace becomes a **zombie**: registered in a VM-wide table, mutated by nobody, but enumerated by every global GC. Ractor death therefore does not stop the world. **Inheritance.** `Ractor#value` (and join) *absorbs* the dead Ractor's objspace into the caller's: pages are spliced wholesale into the caller's heaps (kept sorted by body address for the conservative scan), the finalizer table entries move over, malloc-accounting counters transfer, and the caller's next collection is forced to a full mark to rebuild generational state. This is why `Ractor#value` can return the value **by reference** with no copy: after the absorb it is an ordinary object of the caller. If a dead Ractor's object is itself collected without ever being joined, its orphan objspace is merged into the main Ractor at main's next safepoint (scheduled as a postponed job, because the discovery happens inside a sweep). At VM shutdown all remaining zombies merge into main so at-exit processing sees every object. Because a zombie's pages still hold shareable objects that other Ractors can reach, zombies participate in the global-GC trigger: accumulated zombie pages past a threshold start a global cycle (measured against what survived the previous one, so a zombie with much live data does not retrigger forever). ### 2.6 Message passing: copy `Ractor#send` (copy semantics) became a two-phase protocol: 1. **Snapshot (sender side).** The sender deep-copies the object graph inside its own objspace using a native copier for core types (String, Array, Hash, Struct, T_OBJECT and so on); types the native copier does not support fall back to a Marshal round trip. `#clone` and `initialize_copy` are never invoked, although Marshal-specific hooks (`marshal_dump`, `_dump`) run on the sender for fallback types and can raise there. Every node of the snapshot is **pinned** in the sender's objspace, and the pin cover is handed off without gaps: from the builder's capture list, to the basket (queue entry), to the receiver's materialization frame. A global GC re-pins from whichever holder is current (its clear pass drops all shref bits, see 2.4 step 2/3). 2. **Materialize (receiver side).** When the receiver takes the message out of the port, it rebuilds the graph inside its own objspace. The sender-resident snapshot becomes garbage once the copy completes. Materialization can raise (Marshal load hooks and autoload run user code; asynchronous interrupts can arrive) and can nest (`Ractor.receive` inside a load hook), so in-progress materializations form a per-EC chain of machine-stack frames that the GC uses as roots. Passing the sender-resident graph to the receiver by reference is never allowed: it would create an unshareable cross-objspace edge that neither local GC could handle. ### 2.7 Message passing: move `Ractor#send(obj, move: true)` serializes the payload graph into an off-heap, malloc-allocated structure called the **courier**. While a moved graph is in flight, there is *no heap object* representing it: neither side's GC can mark, sweep, move, or race with it. * A read-only **preflight** walks the graph first and raises every "can not move" error before anything is mutated (capture is destructive, so failing halfway would corrupt the graph beyond repair). * **Capture** turns each source object into a node in the courier's node array (identity and cycles are preserved through node ids and a by-address dedup table) and immediately neutralizes the source into a `Ractor::MovedObject` shell, without ever passing through an inconsistent state that a concurrent marker could observe. * Payload internals move by ownership transfer where possible: a String that owns its buffer hands the pointer over (zero copy); an IO carries its whole `rb_io_t` including the file descriptor; MatchData dumps its match registers into an onig-independent blob. Shareable references and immediates are carried as values; classes of the moved objects are carried as references (classes are shareable), so subclasses and singleton classes survive the move. * In-flight couriers are registered in a VM-global list; a global GC's root pass marks and pins the shareable references they carry (nothing else roots them in some windows). * **Rebuild** on the receiver allocates all shells first, then fills references (two passes break cycles), and inserts Hash entries last, in reverse id order, so user-defined `#hash` methods observe fully built keys. Rebuilding can raise; an unconsumed courier is freed by the queue teardown, closing descriptor leaks for moved IOs. ### 2.8 Generic instance variables Instance variables of non-T_OBJECT hosts (`str.instance_variable_set` and friends) live in one process-global table keyed weakly by host object, guarded by a dedicated leaf mutex. A local GC reads it per object while marking (it cannot wait for the VM lock there); the global GC processes it in the weak pass described above; per-object cleanup happens when a host is freed. An earlier design with per-Ractor tables plus a shared table was abandoned: entries would have had to migrate on every make-shareable, send and inheritance, and that boundary was a reliable source of bugs. ### 2.9 Modular GC API The GC implementation interface (`gc/gc_impl.h`) gains per-Ractor entry points: `multi_objspace_p`, `objspace_absorb`, `objspace_retire_gc`, `obj_foreign_p`, `during_global_gc_p`, `obj_became_shareable`, `pin_in_flight_message`, `each_objects_shareable`, `each_objects_foreign`, `heap_page_count`, `gc_rest`. An implementation that keeps a single objspace (MMTk) reports `multi_objspace_p() == false` and the VM gates the whole per-Ractor machinery out: every Ractor aliases the one objspace, retire/absorb/zombie handling become no-ops, and Ractor-related marking falls back to following everything from the wrapper objects. MMTk continues to pass its test suite unchanged. ## 3. Compatibility * `Ractor#value` becomes a one-shot ownership transfer: the first call hands the value over by reference together with the dead Ractor's heap, and a second call raises `Ractor::Error` ("The value was already taken"). Returning the same unshareable object again would hand out a reference to an object now owned by the first caller, recreating exactly the cross-Ractor sharing that Ractor semantics forbid; special-casing repeat calls from the same Ractor, or shareable values only, would make the API's behavior depend on the caller and the value's type, so the simple rule was chosen. (Previously repeated calls returned the same object.) * `GC.start` in a multi-Ractor process runs a stop-the-world global GC; automatic GC in a Ractor collects only its own heap. * `GC.disable`/`GC.enable` become per-Ractor holds on a process-wide switch: any Ractor's disable stops automatic GC everywhere, but another Ractor's `GC.enable` no longer silently overrides it. A Ractor's hold is released when it exits. * `ObjectSpace.each_object` enumerates the calling Ractor's own objects plus other live Ractors' shareable objects, never their unshareable ones. Objects of a terminated but not yet inherited Ractor are not enumerated until its heap is absorbed. (`ObjectSpace.dump_all` and `memsize_of_all` still cover every object in the process; they walk the other objspaces under a global barrier.) * `ObjectSpace.define_finalizer` on another Ractor's object raises `Ractor::IsolationError`; finalizers are registered, stored and run in the owner's objspace. * The `RUBY_INTERNAL_EVENT_FREEOBJ` tracepoint fires only for the main Ractor's objspace (it runs user callbacks from inside the sweep, which is unsafe in a lock-free non-main sweep; extending it is future work). * Invalidated call-cache entries are no longer pruned during GC marking; they are reclaimed at the next lookup of that method or when the class dies. Retention is bounded (one entry per class-and-method-id pair); the worst-case microbenchmark retains approximately 2.7 MB for 200,000 stale entries. ## 4. Performance Setup: 16 logical CPUs (8 cores, SMT), x86_64-linux, `-O3` release builds. `master` is the merge-base of the PR, `RLGC` is the branch. Every cell is wall-clock seconds until all N workers finish (lower is better). Because the machine's clock state drifts on multi-minute scales, each RLGC/master pair was measured interleaved back to back, and each number is the minimum over at least 10 runs spread across several sessions. All three programs are weak-scaling benchmarks: every worker runs the full workload independently, so with perfect parallelism the wall clock stays flat as N grows. The ratio tables divide every cell by master-Ractor N=1, i.e. "how many times the wall time of current Ruby running one Ractor"; a perfectly scaling configuration keeps its N=1 ratio at every N. "Ractor" rows run the N workers as Ractors, "fork" rows run the identical code in forked processes: the no-shared-GC baseline that Ractors should match. Scripts are about 40 lines each and attached to the PR. ### 4.1 binary-trees (D=16; allocation/GC-heavy) The classic benchmarks-game workload: each worker builds and checksums millions of short-lived binary trees (3-element arrays). Allocation and GC dominate; objects die young. seconds: | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 3.83 | 4.01 | 4.48 | 5.76 | 10.26 | | master Ractor | 3.65 | 4.18 | 5.32 | 8.18 | 14.90 | | RLGC fork | 3.75 | 3.99 | 4.48 | 6.56 | 10.22 | | master fork | 3.66 | 3.90 | 4.40 | 6.40 | 10.03 | ratio (x master-Ractor N=1): | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 1.05 | 1.10 | 1.23 | 1.58 | **2.81** | | master Ractor | 1.00 | 1.15 | 1.46 | 2.24 | 4.08 | | RLGC fork | 1.03 | 1.09 | 1.23 | 1.80 | 2.80 | | master fork | 1.00 | 1.07 | 1.21 | 1.75 | 2.75 | RLGC Ractors scale exactly like forked processes (2.81 vs 2.75-2.80 at N=16); master Ractors are 1.45x behind at N=16. For calibration, a zero-allocation pure-integer loop on this machine goes from 5.9s (N=1) to 10.4s (N=16), so RLGC's 10.26s at N=16 is the hardware's SMT ceiling, not a GC limit. ### 4.2 JSON parse (embarrassingly parallel) Each worker parses its own copy of the same ~30 KB JSON document (200 nested user records) 8000 times and discards each result: pure parse-allocate-discard churn, no data crosses workers. This isolates how allocation and GC scale. seconds: | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 1.59 | 1.70 | 1.89 | 2.61 | 4.93 | | master Ractor | 1.49 | 2.06 | 2.63 | 5.24 | 15.10 | | RLGC fork | 1.50 | 1.73 | 1.88 | 2.53 | 4.87 | | master fork | 1.48 | 1.64 | 1.81 | 2.48 | 4.94 | ratio (x master-Ractor N=1): | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 1.07 | 1.14 | 1.27 | 1.76 | **3.32** | | master Ractor | 1.00 | 1.39 | 1.77 | 3.53 | 10.17 | | RLGC fork | 1.01 | 1.17 | 1.27 | 1.71 | 3.28 | | master fork | 0.99 | 1.10 | 1.22 | 1.67 | 3.33 | master Ractors fall off almost immediately (1.39x already at N=2: every Ractor's allocation churn stops all the others) and end 10x over the baseline at N=16; RLGC matches the fork rows within noise the whole way and finishes 3.1x faster than master Ractors. ### 4.3 JSON producer/consumer (Ractor move vs process IPC) The "coordinator hands work to parsers" pattern: the main Ractor/process produces 20000xN JSON strings (~6 KB each) and distributes them round-robin to N workers, which parse and discard them. The Ractor version transfers each string with `send(move: true)`; the fork version writes it down a pipe (a kernel copy). This measures the data-transfer path, not just parallel GC. seconds: | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 0.77 | 0.89 | 1.05 | 1.59 | 2.71 | | master Ractor | 0.71 | 0.99 | 1.68 | 3.87 | 14.59 | | RLGC fork | 0.80 | 0.85 | 0.98 | 1.87 | 3.02 | | master fork | 0.74 | 0.80 | 0.97 | 1.83 | 3.04 | ratio (x master-Ractor N=1): | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 1.08 | 1.25 | 1.48 | 2.25 | **3.82** | | master Ractor | 1.00 | 1.40 | 2.37 | 5.45 | 20.55 | | RLGC fork | 1.12 | 1.19 | 1.39 | 2.63 | 4.25 | | master fork | 1.04 | 1.12 | 1.37 | 2.58 | 4.29 | At N=16 RLGC's `move` beats even process IPC (2.71s vs 3.02-3.04s: no kernel copy), while master Ractors are 5.4x behind it. The off-heap courier costs ~8% over master's in-heap move at N=1 (0.77 vs 0.71); that is the price of making in-flight messages invisible to both sides' GCs, and a zero-copy fast path remains possible future work. ### 4.4 Single-Ractor performance Measured with no `Ractor.new` at all (the N=1 rows above already run one worker Ractor, which puts the process in multi-Ractor mode). The allocation fast path itself got faster: the per-Ractor newobj cache is gone and objects come straight off the objspace's bump-pointer heaps. | single Ractor, interleaved minima | RLGC | master | | |---------------------------------------|------|--------|------| | `Object.new` x 20M, GC disabled | 1.83 | 2.01 | **-9%** | | `Object.new` x 20M, GC enabled | 1.18 | 1.10 | +7% | | binary-trees (D=16) | 3.79 | 3.71 | +2% | | JSON parse (8000 docs) | 1.55 | 1.49 | +4% | (`Object.new` rows subtract the loop-only baseline of 0.24s.) Pure allocation wins by 9%; sweep-time bitmap maintenance (the shareable and shref bitmaps) gives ~7% back under allocate-and-discard churn, which nets out to +2-4% on GC-heavy end-to-end workloads and less on anything that does real work between allocations. The multi-objspace machinery is gated behind a single-objspace check on hot paths. ### 4.5 Latency Beyond throughput, the qualitative change is who pauses: on master every GC of any Ractor stops all of them, so one allocation-heavy Ractor imposes its GC pauses on every other Ractor in the process. With per-Ractor GC a Ractor pauses only for its own collections; the remaining process-wide pauses are explicit `GC.start`, shareable-population growth and dead-Ractor cleanup, all of which are rare in steady state. ## 5. Limitations and future work * Incremental marking and non-stop-the-world compaction currently run only while the process has a single objspace. Per-objspace incremental marking is possible in principle (the machinery is per objspace already) but needs the single-to-multi transition handled mid-cycle. * `move:` uses an off-heap serialization; a zero-copy fast path (moving whole pages for large graphs) is future work, as is extending the FREEOBJ tracepoint beyond the main Ractor. * A global GC is still fully stop-the-world; making it concurrent or incremental is orthogonal future work. ## 6. Validation `make btest` (2054 tests) and `make test-all` are green on x86_64-linux across release, `RUBY_DEBUG=1`, ASAN, TSAN and MMTk builds. `GC.verify_internal_consistency` includes the containment verifier (`RGENGC_CHECK_MODE` builds run it around every collection); it was used throughout development. The branch adds ~90 Ractor/GC tests covering lifecycle, copy/move semantics, compaction interaction, and regressions for every bug found during a multi-week stress campaign (a corpus of ~1000 generated Ractor programs run under GC.stress, ASAN and TSAN). ## Notes * This work completes the design presented in [Toward Ractor local GC (RubyKaigi 2025)](https://rubykaigi.org/2025/presentations/ko1.html). * Much of this description, and a large part of the code, was written with Claude Code (Fable/Opus). * Acknowledgements * This work started as a collaboration with Rohit Menon. * Thanks to the Shopify Ractor team for their feedback. * And thank you, everyone, for waiting so long for this work. -- https://bugs.ruby-lang.org/
Issue #22227 has been updated by ko1 (Koichi Sasada). Simple web server example: https://claude.ai/code/artifact/eaf033a3-b64b-4a7e-af29-8c45a36109b5 ---------------------------------------- Feature #22227: Per-Ractor GC: collect each Ractor's heap locally, stop the world only when needed https://bugs.ruby-lang.org/issues/22227#change-118370 * Author: ko1 (Koichi Sasada) * Status: Open ---------------------------------------- Companion PR: https://github.com/ruby/ruby/pull/18194 --- ## Abstract This proposal makes the default GC operate per Ractor. Each Ractor owns an objspace (a private heap) and collects it on its own thread without stopping any other Ractor ("local GC"). A stop-the-world collection over all objspaces ("global GC") runs only when something genuinely global has to be reclaimed: shareable objects, memory retained across Ractor boundaries, and the heaps of dead Ractors. With this change, allocation-heavy Ractor programs scale like forked processes, and Ractor communication can even beat interprocess communication; end-to-end single-Ractor workloads stay within 2-4% of current performance (focused allocation microbenchmarks move a few percent in both directions, see section 4.4). ## 1. Motivation Today all Ractors allocate from one shared objspace, and every GC stops every Ractor. This has two consequences: 1. GC pause time grows with the total heap of all Ractors, while the pause still stops everyone. N allocating Ractors trigger GCs N times as often, and each GC scans N heaps. 2. The synchronization needed to stop N mutators at every GC dominates as N grows. In practice, a program that parses JSON in 16 Ractors spends 10x the wall clock of its single-Ractor version (for the same per-worker work), while the identical code in 16 forked processes stays at the hardware's parallel limit of about 3.3x. Ractors currently lose to fork exactly on the workloads they were designed for. The root cause is architectural, not incremental: CRuby's current shared objspace couples every Ractor to the same stop-the-world GC. This proposal splits the heap. ## 2. Design overview Terminology used below: * **objspace**: one Ractor's private heap: its pages, freelists, mark stack, generational state and counters. * **local GC**: a collection of a single objspace, run by its owner thread, with no VM lock and no barrier. Other Ractors keep running. * **global GC**: a stop-the-world mark/sweep over all objspaces as one heap. Heap traversal is exact; machine-stack roots remain conservative. * **shareable object**: `Ractor.shareable?(obj) == true` (immutable values, Class/Module, shareable Procs, and so on). * **shref** ("shareable reference"): an unshareable object directly referenced by a shareable one. Details are given in 2.1 and 2.3. ### 2.1 The containment invariant The correctness of a local GC rests on one invariant:
An unshareable object is reachable only from its owner Ractor, except through edges that the GC has been explicitly told about (shrefs and in-flight messages).
The invariant has an equally important flip side: a **shareable object can be referenced from any Ractor at any time, and nothing records who references it**. A local GC therefore never collects a shareable object, and never even traverses one; it only marks them all as live (2.3). Only a global GC, whose unified mark sees every Ractor's references, can prove a shareable object dead and reclaim it. In other words: unshareable objects are the local GC's responsibility, shareable objects are exclusively the global GC's. Ruby-level Ractor semantics already forbid directly sharing unshareable objects. CRuby, however, has a small number of implementation-level edges that cross the boundary, and the GC work is to make every one of them visible to the collector: * A shareable object can hold an implementation-level reference to an unshareable one: for example, writing through a shareable Proc's environment stores an unshareable value into a shareable env, and a shareable object's hidden instance variables (its fields imemo) can stay unshareable. The **write barrier** detects every such store and records the target as a **shref**. The transition that makes an object shareable likewise registers any pre-existing outgoing edges of this kind (for example, the hidden field values that `Ractor.make_shareable`'s traversal does not reach are shref-recorded at promotion time). * A message in flight (sent, not yet received) lives in the sender's objspace while being reachable only from queue structures. The send path **pins** the payload in the sender's objspace until the receiver materializes it (2.6). Given the invariant, marking one objspace's roots reaches everything a local GC is allowed to free, so a local GC requires no process-wide barrier and never stops another Ractor; its synchronization is confined to leaf mutexes and atomic bitmap operations on shared metadata (2.3). `GC.verify_internal_consistency` walks the heap and checks the invariant edge by edge (builds with `RGENGC_CHECK_MODE` also run it around every collection); it caught most of the bugs found during development. ### 2.2 Heap layout and ownership Every heap page records its owning objspace, so any object's owner is one load away: `GET_HEAP_OBJSPACE(obj)` reads the page header. There is no per-object tag; ownership is a property of the page. Page bodies come from a process-wide **page pool**: large mmap arenas carved into page-sized bodies and recycled through a freelist under a dedicated mutex. Ractors therefore exchange pages (allocation, death, inheritance) without mmap/munmap churn, and without serializing on the kernel's mmap lock. A process-wide **page index** (all pages of all objspaces, sorted by body address) supports the conservative machine-stack scan of a global GC and ownership tests; only stop-the-world code reads it, so it needs no reader lock. Allocation is a bump-pointer fast path (a cursor advancing through free regions) into per-objspace, per-size-class heaps. The upstream per-Ractor newobj cache became unnecessary (the objspace itself is per Ractor), which makes the allocation fast path about 9% faster. ### 2.3 What a local GC does A local GC is the classic incremental/generational collector of CRuby, restricted to one objspace, plus three rules: 1. **Never free a shareable object.** At the end of marking (`gc_marks_finish`), a dedicated pass (`pinned_roots_mark`) marks every shareable object on the objspace's pages that ordinary marking did not reach: mark bit only, no traversal. Another Ractor may hold the only reference to it; only a global GC can prove a shareable object dead. The count of objects kept alive this way is also a useful retention metric (an upper bound on garbage only a global GC can reclaim). 2. **Treat shrefs as roots.** A shref is marked and traversed like a remembered old-to-young target: the shareable parent is never traversed by a local GC (rule 1), so without this its unshareable children would look unreachable. 3. **Never touch a foreign object's GC state.** Marking stops at any object owned by another objspace ("foreign objects are live leaves"): its mark/age/remembered bits belong to its owner, and writing them would race the owner's local GC. Because objects can become shareable at any time between collections, rule 1 scans the shareable/shref bitmaps in every mark cycle rather than trying to maintain a pin set across sweeps. Generational collection stays per objspace. The remembered-set bitmap has exactly one class of cross-thread writers (another Ractor's write barrier remembering a shareable object), and that path uses an atomic bitmap set; everything else is single-writer under the owner's GVL. **Locking model.** A local GC takes neither the VM lock nor any barrier; it never stops or waits for another Ractor. The main Ractor's local GC is the one exception in two bounded windows: it walks VM-global roots and weak tables (`rb_vm_mark`, freeing entries of fstring/symbol/ci tables), which other Ractors rewrite under the VM lock, so it takes a no-barrier VM lock just for those stretches; and when a JIT is enabled, marking reaches JIT payloads of shareable iseqs, which must exclude concurrent compilation. One rule is absolute: a GC must never take the barrier VM lock mid-collection, because the waiter would join a pending global barrier and expose its half-collected heap to the global GC. Shared structures that GC paths touch (page pool, the generic-fields table, registered globals) use their own leaf mutexes instead. **Triggers.** Every local GC entry first asks whether a *global* cycle is needed instead (see 2.4); otherwise the usual malloc/allocation heuristics drive local minor/major cycles per objspace. ### 2.4 What a global GC does A global GC stops the world using the existing barrier mechanism (a GC has no safepoints, so the barrier implicitly waits for every in-flight local GC to finish). Then, with the driver thread executing every phase: 1. **Settle**: finish every objspace's lazy sweep, so mark bits have a single meaning. 2. **Clear**: clear every objspace's mark bits, remembered sets, age metadata and all shref bits. (Missing any of these would leave stale bits and cause use-after-free; this step is why shref bits may only be cleared by a global GC.) 3. **Unified mark**: mark from every Ractor's roots (C-struct roots, machine stacks scanned conservatively against the global page index, VM globals) and traverse exactly, crossing objspace boundaries freely. The write barrier's shref information is re-derived as a byproduct: each shareable-to-unshareable edge encountered re-records the shref bit. In-flight message payloads are re-pinned here (their shref bits were cleared in step 2). 4. **Weak passes**: the generic-fields table (weak keys) is processed to a fixpoint: values (`fields_obj`) of live keys are marked, entries of dead keys are dropped, and since marking a value can make another key live, the pass repeats until no progress. VM-global weak tables (fstring, symbols, call caches and so on) are swept once, not once per objspace. 5. **Sweep**: sweep every objspace inside the barrier (not lazily). This is the only place shareable objects and cross-objspace cycles die. Emptied pages return to the page pool. 6. **Budget**: objspaces left with no free pages get the same heap-growth budget a local full mark would have granted (a global GC bypasses that bookkeeping), so the next allocation does not fail. 7. Shareable-object populations and trigger limits are recounted per objspace. Global GCs are triggered by: an explicit full `GC.start`; growth of the shareable-object population past a per-objspace limit; and accumulation of heap pages held by dead Ractors (see 2.5). Two Ractors deciding to run a global GC at the same time are serialized by the barrier; the second cycle is wasted work, not an error. **Compaction.** With multiple objspaces, `GC.compact` (and autocompact on full marks) runs inside a global GC as three passes over all objspaces: (a) move every objspace's movable objects, leaving forwarding; (b) update references everywhere, following forwarding across objspaces, plus the VM-global side once; (c) sweep and free emptied source pages. It must be three global passes rather than per-objspace work because a reference can point at a moved object in another objspace, and freeing a source page early would let another objspace's update read freed forwarding. A local (non-stop-the-world) compaction runs only while the process has a single objspace; objects that C structs of other Ractors point at (an in-flight payload, a terminated Ractor's pending value) are pinned so they never move even under global compaction. Incremental marking similarly runs only in single-objspace mode; the transition point (`Ractor.new` making the process multi-Ractor) settles any cycle in progress. ### 2.5 Ractor lifecycle and memory hand-off **Creation.** A Ractor's objspace is created before its thread starts, and the child's main Thread and root Fiber wrapper objects are allocated directly into it. Between those allocations and the child joining the Ractor set, the creator "covers" the child objspace through a dedicated slot so a concurrent global GC still enumerates it (an unenumerated objspace would keep stale mark bits). Every failure path of creation (isolation errors, thread-creation failure) hands the child objspace over for reclamation rather than leaking it. **Termination.** A terminating Ractor runs one last local GC with a by-then minimal root set on its own thread ("retire GC": roots are minimal by then, and this reclaims garbage that would otherwise be inherited), and its objspace becomes a **zombie**: registered in a VM-wide table, mutated by nobody, but enumerated by every global GC. Ractor death therefore does not stop the world. **Inheritance.** `Ractor#value` (and join) *absorbs* the dead Ractor's objspace into the caller's: pages are spliced wholesale into the caller's heaps (kept sorted by body address for the conservative scan), the finalizer table entries move over, malloc-accounting counters transfer, and the caller's next collection is forced to a full mark to rebuild generational state. This is why `Ractor#value` can return the value **by reference** with no copy: after the absorb it is an ordinary object of the caller. If a dead Ractor's object is itself collected without ever being joined, its orphan objspace is merged into the main Ractor at main's next safepoint (scheduled as a postponed job, because the discovery happens inside a sweep). At VM shutdown all remaining zombies merge into main so at-exit processing sees every object. Because a zombie's pages still hold shareable objects that other Ractors can reach, zombies participate in the global-GC trigger: accumulated zombie pages past a threshold start a global cycle (measured against what survived the previous one, so a zombie with much live data does not retrigger forever). ### 2.6 Message passing: copy `Ractor#send` (copy semantics) became a two-phase protocol: 1. **Snapshot (sender side).** The sender deep-copies the object graph inside its own objspace using a native copier for core types (String, Array, Hash, Struct, T_OBJECT and so on); types the native copier does not support fall back to a Marshal round trip. `#clone` and `initialize_copy` are never invoked, although Marshal-specific hooks (`marshal_dump`, `_dump`) run on the sender for fallback types and can raise there. Every node of the snapshot is **pinned** in the sender's objspace, and the pin cover is handed off without gaps: from the builder's capture list, to the basket (queue entry), to the receiver's materialization frame. A global GC re-pins from whichever holder is current (its clear pass drops all shref bits, see 2.4 step 2/3). 2. **Materialize (receiver side).** When the receiver takes the message out of the port, it rebuilds the graph inside its own objspace. The sender-resident snapshot becomes garbage once the copy completes. Materialization can raise (Marshal load hooks and autoload run user code; asynchronous interrupts can arrive) and can nest (`Ractor.receive` inside a load hook), so in-progress materializations form a per-EC chain of machine-stack frames that the GC uses as roots. Passing the sender-resident graph to the receiver by reference is never allowed: it would create an unshareable cross-objspace edge that neither local GC could handle. ### 2.7 Message passing: move `Ractor#send(obj, move: true)` serializes the payload graph into an off-heap, malloc-allocated structure called the **courier**. While a moved graph is in flight, there is *no heap object* representing it: neither side's GC can mark, sweep, move, or race with it. * A read-only **preflight** walks the graph first and raises every "can not move" error before anything is mutated (capture is destructive, so failing halfway would corrupt the graph beyond repair). * **Capture** turns each source object into a node in the courier's node array (identity and cycles are preserved through node ids and a by-address dedup table) and immediately neutralizes the source into a `Ractor::MovedObject` shell, without ever passing through an inconsistent state that a concurrent marker could observe. * Payload internals move by ownership transfer where possible: a String that owns its buffer hands the pointer over (zero copy); an IO carries its whole `rb_io_t` including the file descriptor; MatchData dumps its match registers into an onig-independent blob. Shareable references and immediates are carried as values; classes of the moved objects are carried as references (classes are shareable), so subclasses and singleton classes survive the move. * In-flight couriers are registered in a VM-global list; a global GC's root pass marks and pins the shareable references they carry (nothing else roots them in some windows). * **Rebuild** on the receiver allocates all shells first, then fills references (two passes break cycles), and inserts Hash entries last, in reverse id order, so user-defined `#hash` methods observe fully built keys. Rebuilding can raise; an unconsumed courier is freed by the queue teardown, closing descriptor leaks for moved IOs. ### 2.8 Generic instance variables Instance variables of non-T_OBJECT hosts (`str.instance_variable_set` and friends) live in one process-global table keyed weakly by host object, guarded by a dedicated leaf mutex. A local GC reads it per object while marking (it cannot wait for the VM lock there); the global GC processes it in the weak pass described above; per-object cleanup happens when a host is freed. An earlier design with per-Ractor tables plus a shared table was abandoned: entries would have had to migrate on every make-shareable, send and inheritance, and that boundary was a reliable source of bugs. ### 2.9 Modular GC API The GC implementation interface (`gc/gc_impl.h`) gains per-Ractor entry points: `multi_objspace_p`, `objspace_absorb`, `objspace_retire_gc`, `obj_foreign_p`, `during_global_gc_p`, `obj_became_shareable`, `pin_in_flight_message`, `each_objects_shareable`, `each_objects_foreign`, `heap_page_count`, `gc_rest`. An implementation that keeps a single objspace (MMTk) reports `multi_objspace_p() == false` and the VM gates the whole per-Ractor machinery out: every Ractor aliases the one objspace, retire/absorb/zombie handling become no-ops, and Ractor-related marking falls back to following everything from the wrapper objects. MMTk continues to pass its test suite unchanged. ## 3. Compatibility * `Ractor#value` becomes a one-shot ownership transfer: the first call hands the value over by reference together with the dead Ractor's heap, and a second call raises `Ractor::Error` ("The value was already taken"). Returning the same unshareable object again would hand out a reference to an object now owned by the first caller, recreating exactly the cross-Ractor sharing that Ractor semantics forbid; special-casing repeat calls from the same Ractor, or shareable values only, would make the API's behavior depend on the caller and the value's type, so the simple rule was chosen. (Previously repeated calls returned the same object.) * `GC.start` in a multi-Ractor process runs a stop-the-world global GC; automatic GC in a Ractor collects only its own heap. * `GC.disable`/`GC.enable` become per-Ractor holds on a process-wide switch: any Ractor's disable stops automatic GC everywhere, but another Ractor's `GC.enable` no longer silently overrides it. A Ractor's hold is released when it exits. * `ObjectSpace.each_object` enumerates the calling Ractor's own objects plus other live Ractors' shareable objects, never their unshareable ones. Objects of a terminated but not yet inherited Ractor are not enumerated until its heap is absorbed. (`ObjectSpace.dump_all` and `memsize_of_all` still cover every object in the process; they walk the other objspaces under a global barrier.) * `ObjectSpace.define_finalizer` on another Ractor's object raises `Ractor::IsolationError`; finalizers are registered, stored and run in the owner's objspace. * The `RUBY_INTERNAL_EVENT_FREEOBJ` tracepoint fires only for the main Ractor's objspace (it runs user callbacks from inside the sweep, which is unsafe in a lock-free non-main sweep; extending it is future work). * Invalidated call-cache entries are no longer pruned during GC marking; they are reclaimed at the next lookup of that method or when the class dies. Retention is bounded (one entry per class-and-method-id pair); the worst-case microbenchmark retains approximately 2.7 MB for 200,000 stale entries. ## 4. Performance Setup: 16 logical CPUs (8 cores, SMT), x86_64-linux, `-O3` release builds. `master` is the merge-base of the PR, `RLGC` is the branch. Every cell is wall-clock seconds until all N workers finish (lower is better). Because the machine's clock state drifts on multi-minute scales, each RLGC/master pair was measured interleaved back to back, and each number is the minimum over at least 10 runs spread across several sessions. All three programs are weak-scaling benchmarks: every worker runs the full workload independently, so with perfect parallelism the wall clock stays flat as N grows. The ratio tables divide every cell by master-Ractor N=1, i.e. "how many times the wall time of current Ruby running one Ractor"; a perfectly scaling configuration keeps its N=1 ratio at every N. "Ractor" rows run the N workers as Ractors, "fork" rows run the identical code in forked processes: the no-shared-GC baseline that Ractors should match. Scripts are about 40 lines each and attached to the PR. ### 4.1 binary-trees (D=16; allocation/GC-heavy) The classic benchmarks-game workload: each worker builds and checksums millions of short-lived binary trees (3-element arrays). Allocation and GC dominate; objects die young. seconds: | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 3.83 | 4.01 | 4.48 | 5.76 | 10.26 | | master Ractor | 3.65 | 4.18 | 5.32 | 8.18 | 14.90 | | RLGC fork | 3.75 | 3.99 | 4.48 | 6.56 | 10.22 | | master fork | 3.66 | 3.90 | 4.40 | 6.40 | 10.03 | ratio (x master-Ractor N=1): | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 1.05 | 1.10 | 1.23 | 1.58 | **2.81** | | master Ractor | 1.00 | 1.15 | 1.46 | 2.24 | 4.08 | | RLGC fork | 1.03 | 1.09 | 1.23 | 1.80 | 2.80 | | master fork | 1.00 | 1.07 | 1.21 | 1.75 | 2.75 | RLGC Ractors scale exactly like forked processes (2.81 vs 2.75-2.80 at N=16); master Ractors are 1.45x behind at N=16. For calibration, a zero-allocation pure-integer loop on this machine goes from 5.9s (N=1) to 10.4s (N=16), so RLGC's 10.26s at N=16 is the hardware's SMT ceiling, not a GC limit. ### 4.2 JSON parse (embarrassingly parallel) Each worker parses its own copy of the same ~30 KB JSON document (200 nested user records) 8000 times and discards each result: pure parse-allocate-discard churn, no data crosses workers. This isolates how allocation and GC scale. seconds: | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 1.59 | 1.70 | 1.89 | 2.61 | 4.93 | | master Ractor | 1.49 | 2.06 | 2.63 | 5.24 | 15.10 | | RLGC fork | 1.50 | 1.73 | 1.88 | 2.53 | 4.87 | | master fork | 1.48 | 1.64 | 1.81 | 2.48 | 4.94 | ratio (x master-Ractor N=1): | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 1.07 | 1.14 | 1.27 | 1.76 | **3.32** | | master Ractor | 1.00 | 1.39 | 1.77 | 3.53 | 10.17 | | RLGC fork | 1.01 | 1.17 | 1.27 | 1.71 | 3.28 | | master fork | 0.99 | 1.10 | 1.22 | 1.67 | 3.33 | master Ractors fall off almost immediately (1.39x already at N=2: every Ractor's allocation churn stops all the others) and end 10x over the baseline at N=16; RLGC matches the fork rows within noise the whole way and finishes 3.1x faster than master Ractors. ### 4.3 JSON producer/consumer (Ractor move vs process IPC) The "coordinator hands work to parsers" pattern: the main Ractor/process produces 20000xN JSON strings (~6 KB each) and distributes them round-robin to N workers, which parse and discard them. The Ractor version transfers each string with `send(move: true)`; the fork version writes it down a pipe (a kernel copy). This measures the data-transfer path, not just parallel GC. seconds: | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 0.77 | 0.89 | 1.05 | 1.59 | 2.71 | | master Ractor | 0.71 | 0.99 | 1.68 | 3.87 | 14.59 | | RLGC fork | 0.80 | 0.85 | 0.98 | 1.87 | 3.02 | | master fork | 0.74 | 0.80 | 0.97 | 1.83 | 3.04 | ratio (x master-Ractor N=1): | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 1.08 | 1.25 | 1.48 | 2.25 | **3.82** | | master Ractor | 1.00 | 1.40 | 2.37 | 5.45 | 20.55 | | RLGC fork | 1.12 | 1.19 | 1.39 | 2.63 | 4.25 | | master fork | 1.04 | 1.12 | 1.37 | 2.58 | 4.29 | At N=16 RLGC's `move` beats even process IPC (2.71s vs 3.02-3.04s: no kernel copy), while master Ractors are 5.4x behind it. The off-heap courier costs ~8% over master's in-heap move at N=1 (0.77 vs 0.71); that is the price of making in-flight messages invisible to both sides' GCs, and a zero-copy fast path remains possible future work. ### 4.4 Single-Ractor performance Measured with no `Ractor.new` at all (the N=1 rows above already run one worker Ractor, which puts the process in multi-Ractor mode). The allocation fast path itself got faster: the per-Ractor newobj cache is gone and objects come straight off the objspace's bump-pointer heaps. | single Ractor, interleaved minima | RLGC | master | | |---------------------------------------|------|--------|------| | `Object.new` x 20M, GC disabled | 1.83 | 2.01 | **-9%** | | `Object.new` x 20M, GC enabled | 1.18 | 1.10 | +7% | | binary-trees (D=16) | 3.79 | 3.71 | +2% | | JSON parse (8000 docs) | 1.55 | 1.49 | +4% | (`Object.new` rows subtract the loop-only baseline of 0.24s.) Pure allocation wins by 9%; sweep-time bitmap maintenance (the shareable and shref bitmaps) gives ~7% back under allocate-and-discard churn, which nets out to +2-4% on GC-heavy end-to-end workloads and less on anything that does real work between allocations. The multi-objspace machinery is gated behind a single-objspace check on hot paths. ### 4.5 Latency Beyond throughput, the qualitative change is who pauses: on master every GC of any Ractor stops all of them, so one allocation-heavy Ractor imposes its GC pauses on every other Ractor in the process. With per-Ractor GC a Ractor pauses only for its own collections; the remaining process-wide pauses are explicit `GC.start`, shareable-population growth and dead-Ractor cleanup, all of which are rare in steady state. ## 5. Limitations and future work * Incremental marking and non-stop-the-world compaction currently run only while the process has a single objspace. Per-objspace incremental marking is possible in principle (the machinery is per objspace already) but needs the single-to-multi transition handled mid-cycle. * `move:` uses an off-heap serialization; a zero-copy fast path (moving whole pages for large graphs) is future work, as is extending the FREEOBJ tracepoint beyond the main Ractor. * A global GC is still fully stop-the-world; making it concurrent or incremental is orthogonal future work. ## 6. Validation `make btest` (2054 tests) and `make test-all` are green on x86_64-linux across release, `RUBY_DEBUG=1`, ASAN, TSAN and MMTk builds. `GC.verify_internal_consistency` includes the containment verifier (`RGENGC_CHECK_MODE` builds run it around every collection); it was used throughout development. The branch adds ~90 Ractor/GC tests covering lifecycle, copy/move semantics, compaction interaction, and regressions for every bug found during a multi-week stress campaign (a corpus of ~1000 generated Ractor programs run under GC.stress, ASAN and TSAN). ## Notes * This work completes the design presented in [Toward Ractor local GC (RubyKaigi 2025)](https://rubykaigi.org/2025/presentations/ko1.html). * Much of this description, and a large part of the code, was written with Claude Code (Fable/Opus). * Acknowledgements * This work started as a collaboration with Rohit Menon. * Thanks to the Shopify Ractor team for their feedback. * And thank you, everyone, for waiting so long for this work. -- https://bugs.ruby-lang.org/
Issue #22227 has been updated by matz (Yukihiro Matsumoto). I approve the direction. Ractors were designed for exactly the workloads where they currently lose to `fork`, and closing that gap is worth an architectural change of this size. The numbers in section 4 are convincing, and keeping single-Ractor programs within a few percent was the right constraint to hold yourself to. One point I am not ready to decide: `Ractor#value` becoming one-shot. Your reason in section 3 is that special-casing shareable values would make the behavior depend on the value's type. But the Ractor API already depends on that distinction everywhere; it is the central distinction of the whole design. So I do not think it is disqualifying here. For a shareable return value, the objspace absorb happens on the first call, and returning the same object again afterwards seems harmless. Could you explain whether that is difficult in the implementation, or whether you have another reason to prefer the uniform rule? I would like to understand that before deciding. Everything else can proceed. Matz. ---------------------------------------- Feature #22227: Per-Ractor GC: collect each Ractor's heap locally, stop the world only when needed https://bugs.ruby-lang.org/issues/22227#change-118412 * Author: ko1 (Koichi Sasada) * Status: Open ---------------------------------------- Companion PR: https://github.com/ruby/ruby/pull/18194 --- ## Abstract This proposal makes the default GC operate per Ractor. Each Ractor owns an objspace (a private heap) and collects it on its own thread without stopping any other Ractor ("local GC"). A stop-the-world collection over all objspaces ("global GC") runs only when something genuinely global has to be reclaimed: shareable objects, memory retained across Ractor boundaries, and the heaps of dead Ractors. With this change, allocation-heavy Ractor programs scale like forked processes, and Ractor communication can even beat interprocess communication; end-to-end single-Ractor workloads stay within 2-4% of current performance (focused allocation microbenchmarks move a few percent in both directions, see section 4.4). ## 1. Motivation Today all Ractors allocate from one shared objspace, and every GC stops every Ractor. This has two consequences: 1. GC pause time grows with the total heap of all Ractors, while the pause still stops everyone. N allocating Ractors trigger GCs N times as often, and each GC scans N heaps. 2. The synchronization needed to stop N mutators at every GC dominates as N grows. In practice, a program that parses JSON in 16 Ractors spends 10x the wall clock of its single-Ractor version (for the same per-worker work), while the identical code in 16 forked processes stays at the hardware's parallel limit of about 3.3x. Ractors currently lose to fork exactly on the workloads they were designed for. The root cause is architectural, not incremental: CRuby's current shared objspace couples every Ractor to the same stop-the-world GC. This proposal splits the heap. ## 2. Design overview Terminology used below: * **objspace**: one Ractor's private heap: its pages, freelists, mark stack, generational state and counters. * **local GC**: a collection of a single objspace, run by its owner thread, with no VM lock and no barrier. Other Ractors keep running. * **global GC**: a stop-the-world mark/sweep over all objspaces as one heap. Heap traversal is exact; machine-stack roots remain conservative. * **shareable object**: `Ractor.shareable?(obj) == true` (immutable values, Class/Module, shareable Procs, and so on). * **shref** ("shareable reference"): an unshareable object directly referenced by a shareable one. Details are given in 2.1 and 2.3. ### 2.1 The containment invariant The correctness of a local GC rests on one invariant:
An unshareable object is reachable only from its owner Ractor, except through edges that the GC has been explicitly told about (shrefs and in-flight messages).
The invariant has an equally important flip side: a **shareable object can be referenced from any Ractor at any time, and nothing records who references it**. A local GC therefore never collects a shareable object, and never even traverses one; it only marks them all as live (2.3). Only a global GC, whose unified mark sees every Ractor's references, can prove a shareable object dead and reclaim it. In other words: unshareable objects are the local GC's responsibility, shareable objects are exclusively the global GC's. Ruby-level Ractor semantics already forbid directly sharing unshareable objects. CRuby, however, has a small number of implementation-level edges that cross the boundary, and the GC work is to make every one of them visible to the collector: * A shareable object can hold an implementation-level reference to an unshareable one: for example, writing through a shareable Proc's environment stores an unshareable value into a shareable env, and a shareable object's hidden instance variables (its fields imemo) can stay unshareable. The **write barrier** detects every such store and records the target as a **shref**. The transition that makes an object shareable likewise registers any pre-existing outgoing edges of this kind (for example, the hidden field values that `Ractor.make_shareable`'s traversal does not reach are shref-recorded at promotion time). * A message in flight (sent, not yet received) lives in the sender's objspace while being reachable only from queue structures. The send path **pins** the payload in the sender's objspace until the receiver materializes it (2.6). Given the invariant, marking one objspace's roots reaches everything a local GC is allowed to free, so a local GC requires no process-wide barrier and never stops another Ractor; its synchronization is confined to leaf mutexes and atomic bitmap operations on shared metadata (2.3). `GC.verify_internal_consistency` walks the heap and checks the invariant edge by edge (builds with `RGENGC_CHECK_MODE` also run it around every collection); it caught most of the bugs found during development. ### 2.2 Heap layout and ownership Every heap page records its owning objspace, so any object's owner is one load away: `GET_HEAP_OBJSPACE(obj)` reads the page header. There is no per-object tag; ownership is a property of the page. Page bodies come from a process-wide **page pool**: large mmap arenas carved into page-sized bodies and recycled through a freelist under a dedicated mutex. Ractors therefore exchange pages (allocation, death, inheritance) without mmap/munmap churn, and without serializing on the kernel's mmap lock. A process-wide **page index** (all pages of all objspaces, sorted by body address) supports the conservative machine-stack scan of a global GC and ownership tests; only stop-the-world code reads it, so it needs no reader lock. Allocation is a bump-pointer fast path (a cursor advancing through free regions) into per-objspace, per-size-class heaps. The upstream per-Ractor newobj cache became unnecessary (the objspace itself is per Ractor), which makes the allocation fast path about 9% faster. ### 2.3 What a local GC does A local GC is the classic incremental/generational collector of CRuby, restricted to one objspace, plus three rules: 1. **Never free a shareable object.** At the end of marking (`gc_marks_finish`), a dedicated pass (`pinned_roots_mark`) marks every shareable object on the objspace's pages that ordinary marking did not reach: mark bit only, no traversal. Another Ractor may hold the only reference to it; only a global GC can prove a shareable object dead. The count of objects kept alive this way is also a useful retention metric (an upper bound on garbage only a global GC can reclaim). 2. **Treat shrefs as roots.** A shref is marked and traversed like a remembered old-to-young target: the shareable parent is never traversed by a local GC (rule 1), so without this its unshareable children would look unreachable. 3. **Never touch a foreign object's GC state.** Marking stops at any object owned by another objspace ("foreign objects are live leaves"): its mark/age/remembered bits belong to its owner, and writing them would race the owner's local GC. Because objects can become shareable at any time between collections, rule 1 scans the shareable/shref bitmaps in every mark cycle rather than trying to maintain a pin set across sweeps. Generational collection stays per objspace. The remembered-set bitmap has exactly one class of cross-thread writers (another Ractor's write barrier remembering a shareable object), and that path uses an atomic bitmap set; everything else is single-writer under the owner's GVL. **Locking model.** A local GC takes neither the VM lock nor any barrier; it never stops or waits for another Ractor. The main Ractor's local GC is the one exception in two bounded windows: it walks VM-global roots and weak tables (`rb_vm_mark`, freeing entries of fstring/symbol/ci tables), which other Ractors rewrite under the VM lock, so it takes a no-barrier VM lock just for those stretches; and when a JIT is enabled, marking reaches JIT payloads of shareable iseqs, which must exclude concurrent compilation. One rule is absolute: a GC must never take the barrier VM lock mid-collection, because the waiter would join a pending global barrier and expose its half-collected heap to the global GC. Shared structures that GC paths touch (page pool, the generic-fields table, registered globals) use their own leaf mutexes instead. **Triggers.** Every local GC entry first asks whether a *global* cycle is needed instead (see 2.4); otherwise the usual malloc/allocation heuristics drive local minor/major cycles per objspace. ### 2.4 What a global GC does A global GC stops the world using the existing barrier mechanism (a GC has no safepoints, so the barrier implicitly waits for every in-flight local GC to finish). Then, with the driver thread executing every phase: 1. **Settle**: finish every objspace's lazy sweep, so mark bits have a single meaning. 2. **Clear**: clear every objspace's mark bits, remembered sets, age metadata and all shref bits. (Missing any of these would leave stale bits and cause use-after-free; this step is why shref bits may only be cleared by a global GC.) 3. **Unified mark**: mark from every Ractor's roots (C-struct roots, machine stacks scanned conservatively against the global page index, VM globals) and traverse exactly, crossing objspace boundaries freely. The write barrier's shref information is re-derived as a byproduct: each shareable-to-unshareable edge encountered re-records the shref bit. In-flight message payloads are re-pinned here (their shref bits were cleared in step 2). 4. **Weak passes**: the generic-fields table (weak keys) is processed to a fixpoint: values (`fields_obj`) of live keys are marked, entries of dead keys are dropped, and since marking a value can make another key live, the pass repeats until no progress. VM-global weak tables (fstring, symbols, call caches and so on) are swept once, not once per objspace. 5. **Sweep**: sweep every objspace inside the barrier (not lazily). This is the only place shareable objects and cross-objspace cycles die. Emptied pages return to the page pool. 6. **Budget**: objspaces left with no free pages get the same heap-growth budget a local full mark would have granted (a global GC bypasses that bookkeeping), so the next allocation does not fail. 7. Shareable-object populations and trigger limits are recounted per objspace. Global GCs are triggered by: an explicit full `GC.start`; growth of the shareable-object population past a per-objspace limit; and accumulation of heap pages held by dead Ractors (see 2.5). Two Ractors deciding to run a global GC at the same time are serialized by the barrier; the second cycle is wasted work, not an error. **Compaction.** With multiple objspaces, `GC.compact` (and autocompact on full marks) runs inside a global GC as three passes over all objspaces: (a) move every objspace's movable objects, leaving forwarding; (b) update references everywhere, following forwarding across objspaces, plus the VM-global side once; (c) sweep and free emptied source pages. It must be three global passes rather than per-objspace work because a reference can point at a moved object in another objspace, and freeing a source page early would let another objspace's update read freed forwarding. A local (non-stop-the-world) compaction runs only while the process has a single objspace; objects that C structs of other Ractors point at (an in-flight payload, a terminated Ractor's pending value) are pinned so they never move even under global compaction. Incremental marking similarly runs only in single-objspace mode; the transition point (`Ractor.new` making the process multi-Ractor) settles any cycle in progress. ### 2.5 Ractor lifecycle and memory hand-off **Creation.** A Ractor's objspace is created before its thread starts, and the child's main Thread and root Fiber wrapper objects are allocated directly into it. Between those allocations and the child joining the Ractor set, the creator "covers" the child objspace through a dedicated slot so a concurrent global GC still enumerates it (an unenumerated objspace would keep stale mark bits). Every failure path of creation (isolation errors, thread-creation failure) hands the child objspace over for reclamation rather than leaking it. **Termination.** A terminating Ractor runs one last local GC with a by-then minimal root set on its own thread ("retire GC": roots are minimal by then, and this reclaims garbage that would otherwise be inherited), and its objspace becomes a **zombie**: registered in a VM-wide table, mutated by nobody, but enumerated by every global GC. Ractor death therefore does not stop the world. **Inheritance.** `Ractor#value` (and join) *absorbs* the dead Ractor's objspace into the caller's: pages are spliced wholesale into the caller's heaps (kept sorted by body address for the conservative scan), the finalizer table entries move over, malloc-accounting counters transfer, and the caller's next collection is forced to a full mark to rebuild generational state. This is why `Ractor#value` can return the value **by reference** with no copy: after the absorb it is an ordinary object of the caller. If a dead Ractor's object is itself collected without ever being joined, its orphan objspace is merged into the main Ractor at main's next safepoint (scheduled as a postponed job, because the discovery happens inside a sweep). At VM shutdown all remaining zombies merge into main so at-exit processing sees every object. Because a zombie's pages still hold shareable objects that other Ractors can reach, zombies participate in the global-GC trigger: accumulated zombie pages past a threshold start a global cycle (measured against what survived the previous one, so a zombie with much live data does not retrigger forever). ### 2.6 Message passing: copy `Ractor#send` (copy semantics) became a two-phase protocol: 1. **Snapshot (sender side).** The sender deep-copies the object graph inside its own objspace using a native copier for core types (String, Array, Hash, Struct, T_OBJECT and so on); types the native copier does not support fall back to a Marshal round trip. `#clone` and `initialize_copy` are never invoked, although Marshal-specific hooks (`marshal_dump`, `_dump`) run on the sender for fallback types and can raise there. Every node of the snapshot is **pinned** in the sender's objspace, and the pin cover is handed off without gaps: from the builder's capture list, to the basket (queue entry), to the receiver's materialization frame. A global GC re-pins from whichever holder is current (its clear pass drops all shref bits, see 2.4 step 2/3). 2. **Materialize (receiver side).** When the receiver takes the message out of the port, it rebuilds the graph inside its own objspace. The sender-resident snapshot becomes garbage once the copy completes. Materialization can raise (Marshal load hooks and autoload run user code; asynchronous interrupts can arrive) and can nest (`Ractor.receive` inside a load hook), so in-progress materializations form a per-EC chain of machine-stack frames that the GC uses as roots. Passing the sender-resident graph to the receiver by reference is never allowed: it would create an unshareable cross-objspace edge that neither local GC could handle. ### 2.7 Message passing: move `Ractor#send(obj, move: true)` serializes the payload graph into an off-heap, malloc-allocated structure called the **courier**. While a moved graph is in flight, there is *no heap object* representing it: neither side's GC can mark, sweep, move, or race with it. * A read-only **preflight** walks the graph first and raises every "can not move" error before anything is mutated (capture is destructive, so failing halfway would corrupt the graph beyond repair). * **Capture** turns each source object into a node in the courier's node array (identity and cycles are preserved through node ids and a by-address dedup table) and immediately neutralizes the source into a `Ractor::MovedObject` shell, without ever passing through an inconsistent state that a concurrent marker could observe. * Payload internals move by ownership transfer where possible: a String that owns its buffer hands the pointer over (zero copy); an IO carries its whole `rb_io_t` including the file descriptor; MatchData dumps its match registers into an onig-independent blob. Shareable references and immediates are carried as values; classes of the moved objects are carried as references (classes are shareable), so subclasses and singleton classes survive the move. * In-flight couriers are registered in a VM-global list; a global GC's root pass marks and pins the shareable references they carry (nothing else roots them in some windows). * **Rebuild** on the receiver allocates all shells first, then fills references (two passes break cycles), and inserts Hash entries last, in reverse id order, so user-defined `#hash` methods observe fully built keys. Rebuilding can raise; an unconsumed courier is freed by the queue teardown, closing descriptor leaks for moved IOs. ### 2.8 Generic instance variables Instance variables of non-T_OBJECT hosts (`str.instance_variable_set` and friends) live in one process-global table keyed weakly by host object, guarded by a dedicated leaf mutex. A local GC reads it per object while marking (it cannot wait for the VM lock there); the global GC processes it in the weak pass described above; per-object cleanup happens when a host is freed. An earlier design with per-Ractor tables plus a shared table was abandoned: entries would have had to migrate on every make-shareable, send and inheritance, and that boundary was a reliable source of bugs. ### 2.9 Modular GC API The GC implementation interface (`gc/gc_impl.h`) gains per-Ractor entry points: `multi_objspace_p`, `objspace_absorb`, `objspace_retire_gc`, `obj_foreign_p`, `during_global_gc_p`, `obj_became_shareable`, `pin_in_flight_message`, `each_objects_shareable`, `each_objects_foreign`, `heap_page_count`, `gc_rest`. An implementation that keeps a single objspace (MMTk) reports `multi_objspace_p() == false` and the VM gates the whole per-Ractor machinery out: every Ractor aliases the one objspace, retire/absorb/zombie handling become no-ops, and Ractor-related marking falls back to following everything from the wrapper objects. MMTk continues to pass its test suite unchanged. ## 3. Compatibility * `Ractor#value` becomes a one-shot ownership transfer: the first call hands the value over by reference together with the dead Ractor's heap, and a second call raises `Ractor::Error` ("The value was already taken"). Returning the same unshareable object again would hand out a reference to an object now owned by the first caller, recreating exactly the cross-Ractor sharing that Ractor semantics forbid; special-casing repeat calls from the same Ractor, or shareable values only, would make the API's behavior depend on the caller and the value's type, so the simple rule was chosen. (Previously repeated calls returned the same object.) * `GC.start` in a multi-Ractor process runs a stop-the-world global GC; automatic GC in a Ractor collects only its own heap. * `GC.disable`/`GC.enable` become per-Ractor holds on a process-wide switch: any Ractor's disable stops automatic GC everywhere, but another Ractor's `GC.enable` no longer silently overrides it. A Ractor's hold is released when it exits. * `ObjectSpace.each_object` enumerates the calling Ractor's own objects plus other live Ractors' shareable objects, never their unshareable ones. Objects of a terminated but not yet inherited Ractor are not enumerated until its heap is absorbed. (`ObjectSpace.dump_all` and `memsize_of_all` still cover every object in the process; they walk the other objspaces under a global barrier.) * `ObjectSpace.define_finalizer` on another Ractor's object raises `Ractor::IsolationError`; finalizers are registered, stored and run in the owner's objspace. * The `RUBY_INTERNAL_EVENT_FREEOBJ` tracepoint fires only for the main Ractor's objspace (it runs user callbacks from inside the sweep, which is unsafe in a lock-free non-main sweep; extending it is future work). * Invalidated call-cache entries are no longer pruned during GC marking; they are reclaimed at the next lookup of that method or when the class dies. Retention is bounded (one entry per class-and-method-id pair); the worst-case microbenchmark retains approximately 2.7 MB for 200,000 stale entries. ## 4. Performance Setup: 16 logical CPUs (8 cores, SMT), x86_64-linux, `-O3` release builds. `master` is the merge-base of the PR, `RLGC` is the branch. Every cell is wall-clock seconds until all N workers finish (lower is better). Because the machine's clock state drifts on multi-minute scales, each RLGC/master pair was measured interleaved back to back, and each number is the minimum over at least 10 runs spread across several sessions. All three programs are weak-scaling benchmarks: every worker runs the full workload independently, so with perfect parallelism the wall clock stays flat as N grows. The ratio tables divide every cell by master-Ractor N=1, i.e. "how many times the wall time of current Ruby running one Ractor"; a perfectly scaling configuration keeps its N=1 ratio at every N. "Ractor" rows run the N workers as Ractors, "fork" rows run the identical code in forked processes: the no-shared-GC baseline that Ractors should match. Scripts are about 40 lines each and attached to the PR. ### 4.1 binary-trees (D=16; allocation/GC-heavy) The classic benchmarks-game workload: each worker builds and checksums millions of short-lived binary trees (3-element arrays). Allocation and GC dominate; objects die young. seconds: | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 3.83 | 4.01 | 4.48 | 5.76 | 10.26 | | master Ractor | 3.65 | 4.18 | 5.32 | 8.18 | 14.90 | | RLGC fork | 3.75 | 3.99 | 4.48 | 6.56 | 10.22 | | master fork | 3.66 | 3.90 | 4.40 | 6.40 | 10.03 | ratio (x master-Ractor N=1): | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 1.05 | 1.10 | 1.23 | 1.58 | **2.81** | | master Ractor | 1.00 | 1.15 | 1.46 | 2.24 | 4.08 | | RLGC fork | 1.03 | 1.09 | 1.23 | 1.80 | 2.80 | | master fork | 1.00 | 1.07 | 1.21 | 1.75 | 2.75 | RLGC Ractors scale exactly like forked processes (2.81 vs 2.75-2.80 at N=16); master Ractors are 1.45x behind at N=16. For calibration, a zero-allocation pure-integer loop on this machine goes from 5.9s (N=1) to 10.4s (N=16), so RLGC's 10.26s at N=16 is the hardware's SMT ceiling, not a GC limit. ### 4.2 JSON parse (embarrassingly parallel) Each worker parses its own copy of the same ~30 KB JSON document (200 nested user records) 8000 times and discards each result: pure parse-allocate-discard churn, no data crosses workers. This isolates how allocation and GC scale. seconds: | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 1.59 | 1.70 | 1.89 | 2.61 | 4.93 | | master Ractor | 1.49 | 2.06 | 2.63 | 5.24 | 15.10 | | RLGC fork | 1.50 | 1.73 | 1.88 | 2.53 | 4.87 | | master fork | 1.48 | 1.64 | 1.81 | 2.48 | 4.94 | ratio (x master-Ractor N=1): | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 1.07 | 1.14 | 1.27 | 1.76 | **3.32** | | master Ractor | 1.00 | 1.39 | 1.77 | 3.53 | 10.17 | | RLGC fork | 1.01 | 1.17 | 1.27 | 1.71 | 3.28 | | master fork | 0.99 | 1.10 | 1.22 | 1.67 | 3.33 | master Ractors fall off almost immediately (1.39x already at N=2: every Ractor's allocation churn stops all the others) and end 10x over the baseline at N=16; RLGC matches the fork rows within noise the whole way and finishes 3.1x faster than master Ractors. ### 4.3 JSON producer/consumer (Ractor move vs process IPC) The "coordinator hands work to parsers" pattern: the main Ractor/process produces 20000xN JSON strings (~6 KB each) and distributes them round-robin to N workers, which parse and discard them. The Ractor version transfers each string with `send(move: true)`; the fork version writes it down a pipe (a kernel copy). This measures the data-transfer path, not just parallel GC. seconds: | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 0.77 | 0.89 | 1.05 | 1.59 | 2.71 | | master Ractor | 0.71 | 0.99 | 1.68 | 3.87 | 14.59 | | RLGC fork | 0.80 | 0.85 | 0.98 | 1.87 | 3.02 | | master fork | 0.74 | 0.80 | 0.97 | 1.83 | 3.04 | ratio (x master-Ractor N=1): | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 1.08 | 1.25 | 1.48 | 2.25 | **3.82** | | master Ractor | 1.00 | 1.40 | 2.37 | 5.45 | 20.55 | | RLGC fork | 1.12 | 1.19 | 1.39 | 2.63 | 4.25 | | master fork | 1.04 | 1.12 | 1.37 | 2.58 | 4.29 | At N=16 RLGC's `move` beats even process IPC (2.71s vs 3.02-3.04s: no kernel copy), while master Ractors are 5.4x behind it. The off-heap courier costs ~8% over master's in-heap move at N=1 (0.77 vs 0.71); that is the price of making in-flight messages invisible to both sides' GCs, and a zero-copy fast path remains possible future work. ### 4.4 Single-Ractor performance Measured with no `Ractor.new` at all (the N=1 rows above already run one worker Ractor, which puts the process in multi-Ractor mode). The allocation fast path itself got faster: the per-Ractor newobj cache is gone and objects come straight off the objspace's bump-pointer heaps. | single Ractor, interleaved minima | RLGC | master | | |---------------------------------------|------|--------|------| | `Object.new` x 20M, GC disabled | 1.83 | 2.01 | **-9%** | | `Object.new` x 20M, GC enabled | 1.18 | 1.10 | +7% | | binary-trees (D=16) | 3.79 | 3.71 | +2% | | JSON parse (8000 docs) | 1.55 | 1.49 | +4% | (`Object.new` rows subtract the loop-only baseline of 0.24s.) Pure allocation wins by 9%; sweep-time bitmap maintenance (the shareable and shref bitmaps) gives ~7% back under allocate-and-discard churn, which nets out to +2-4% on GC-heavy end-to-end workloads and less on anything that does real work between allocations. The multi-objspace machinery is gated behind a single-objspace check on hot paths. ### 4.5 Latency Beyond throughput, the qualitative change is who pauses: on master every GC of any Ractor stops all of them, so one allocation-heavy Ractor imposes its GC pauses on every other Ractor in the process. With per-Ractor GC a Ractor pauses only for its own collections; the remaining process-wide pauses are explicit `GC.start`, shareable-population growth and dead-Ractor cleanup, all of which are rare in steady state. ## 5. Limitations and future work * Incremental marking and non-stop-the-world compaction currently run only while the process has a single objspace. Per-objspace incremental marking is possible in principle (the machinery is per objspace already) but needs the single-to-multi transition handled mid-cycle. * `move:` uses an off-heap serialization; a zero-copy fast path (moving whole pages for large graphs) is future work, as is extending the FREEOBJ tracepoint beyond the main Ractor. * A global GC is still fully stop-the-world; making it concurrent or incremental is orthogonal future work. ## 6. Validation `make btest` (2054 tests) and `make test-all` are green on x86_64-linux across release, `RUBY_DEBUG=1`, ASAN, TSAN and MMTk builds. `GC.verify_internal_consistency` includes the containment verifier (`RGENGC_CHECK_MODE` builds run it around every collection); it was used throughout development. The branch adds ~90 Ractor/GC tests covering lifecycle, copy/move semantics, compaction interaction, and regressions for every bug found during a multi-week stress campaign (a corpus of ~1000 generated Ractor programs run under GC.stress, ASAN and TSAN). ## Notes * This work completes the design presented in [Toward Ractor local GC (RubyKaigi 2025)](https://rubykaigi.org/2025/presentations/ko1.html). * Much of this description, and a large part of the code, was written with Claude Code (Fable/Opus). * Acknowledgements * This work started as a collaboration with Rohit Menon. * Thanks to the Shopify Ractor team for their feedback. * And thank you, everyone, for waiting so long for this work. -- https://bugs.ruby-lang.org/
Issue #22227 has been updated by ko1 (Koichi Sasada). matz (Yukihiro Matsumoto) wrote in #note-5:
Could you explain whether that is difficult in the implementation, or whether you have another reason to prefer the uniform rule? I would like to understand that before deciding.
Ractor#value returns the value by reference, not by copy. To do that without breaking containment, it first absorbs the terminated Ractor's objspace into the caller's. Take three Ractors: A creates B, and C calls B.value. After the absorb, the value lives in C's objspace, while the reference to it still sits in B's struct. Now ask who marks that value: - A marks B — it holds B's Ractor object. But the value belongs to C's objspace, so A's local GC does not mark it. - C does not mark B — it holds no reference to B's Ractor object, so it never reaches the value through B's struct. The reference and the object have come apart: the slot holding it belongs to one Ractor, the object itself to another, and neither local GC bridges the gap. C's next collection frees the value. With a one-shot value, that mismatch only exists between B terminating and C taking the value. B's own marking covers that window, and once the value is handed over it is an ordinary Ruby return value, rooted by the caller's variable like anything else. Nothing has to track it afterwards. Making value repeatable means keeping the arrangement valid for as long as B is reachable. The way to do that is for C to keep a table from B to the value, with B as a weak key, so C's own collection reaches the value and the entry disappears once B is gone. That is workable — and being an ordinary object rather than a C struct slot, it needs no pin — but it is a per-Ractor weak table with its GC integration, a clean-up path, and a merge step when C is itself absorbed. Since a Ractor object is shareable, the weak key is only cleared by a global collection, so values linger a cycle longer than the program would suggest. So the question is not whether repeatable value can be made to work; it is whether it is worth that machinery. One-shot costs nothing: the value stops being the runtime's problem the moment it is returned. ---------------------------------------- Feature #22227: Per-Ractor GC: collect each Ractor's heap locally, stop the world only when needed https://bugs.ruby-lang.org/issues/22227#change-118420 * Author: ko1 (Koichi Sasada) * Status: Open ---------------------------------------- Companion PR: https://github.com/ruby/ruby/pull/18194 --- ## Abstract This proposal makes the default GC operate per Ractor. Each Ractor owns an objspace (a private heap) and collects it on its own thread without stopping any other Ractor ("local GC"). A stop-the-world collection over all objspaces ("global GC") runs only when something genuinely global has to be reclaimed: shareable objects, memory retained across Ractor boundaries, and the heaps of dead Ractors. With this change, allocation-heavy Ractor programs scale like forked processes, and Ractor communication can even beat interprocess communication; end-to-end single-Ractor workloads stay within 2-4% of current performance (focused allocation microbenchmarks move a few percent in both directions, see section 4.4). ## 1. Motivation Today all Ractors allocate from one shared objspace, and every GC stops every Ractor. This has two consequences: 1. GC pause time grows with the total heap of all Ractors, while the pause still stops everyone. N allocating Ractors trigger GCs N times as often, and each GC scans N heaps. 2. The synchronization needed to stop N mutators at every GC dominates as N grows. In practice, a program that parses JSON in 16 Ractors spends 10x the wall clock of its single-Ractor version (for the same per-worker work), while the identical code in 16 forked processes stays at the hardware's parallel limit of about 3.3x. Ractors currently lose to fork exactly on the workloads they were designed for. The root cause is architectural, not incremental: CRuby's current shared objspace couples every Ractor to the same stop-the-world GC. This proposal splits the heap. ## 2. Design overview Terminology used below: * **objspace**: one Ractor's private heap: its pages, freelists, mark stack, generational state and counters. * **local GC**: a collection of a single objspace, run by its owner thread, with no VM lock and no barrier. Other Ractors keep running. * **global GC**: a stop-the-world mark/sweep over all objspaces as one heap. Heap traversal is exact; machine-stack roots remain conservative. * **shareable object**: `Ractor.shareable?(obj) == true` (immutable values, Class/Module, shareable Procs, and so on). * **shref** ("shareable reference"): an unshareable object directly referenced by a shareable one. Details are given in 2.1 and 2.3. ### 2.1 The containment invariant The correctness of a local GC rests on one invariant:
An unshareable object is reachable only from its owner Ractor, except through edges that the GC has been explicitly told about (shrefs and in-flight messages).
The invariant has an equally important flip side: a **shareable object can be referenced from any Ractor at any time, and nothing records who references it**. A local GC therefore never collects a shareable object, and never even traverses one; it only marks them all as live (2.3). Only a global GC, whose unified mark sees every Ractor's references, can prove a shareable object dead and reclaim it. In other words: unshareable objects are the local GC's responsibility, shareable objects are exclusively the global GC's. Ruby-level Ractor semantics already forbid directly sharing unshareable objects. CRuby, however, has a small number of implementation-level edges that cross the boundary, and the GC work is to make every one of them visible to the collector: * A shareable object can hold an implementation-level reference to an unshareable one: for example, writing through a shareable Proc's environment stores an unshareable value into a shareable env, and a shareable object's hidden instance variables (its fields imemo) can stay unshareable. The **write barrier** detects every such store and records the target as a **shref**. The transition that makes an object shareable likewise registers any pre-existing outgoing edges of this kind (for example, the hidden field values that `Ractor.make_shareable`'s traversal does not reach are shref-recorded at promotion time). * A message in flight (sent, not yet received) lives in the sender's objspace while being reachable only from queue structures. The send path **pins** the payload in the sender's objspace until the receiver materializes it (2.6). Given the invariant, marking one objspace's roots reaches everything a local GC is allowed to free, so a local GC requires no process-wide barrier and never stops another Ractor; its synchronization is confined to leaf mutexes and atomic bitmap operations on shared metadata (2.3). `GC.verify_internal_consistency` walks the heap and checks the invariant edge by edge (builds with `RGENGC_CHECK_MODE` also run it around every collection); it caught most of the bugs found during development. ### 2.2 Heap layout and ownership Every heap page records its owning objspace, so any object's owner is one load away: `GET_HEAP_OBJSPACE(obj)` reads the page header. There is no per-object tag; ownership is a property of the page. Page bodies come from a process-wide **page pool**: large mmap arenas carved into page-sized bodies and recycled through a freelist under a dedicated mutex. Ractors therefore exchange pages (allocation, death, inheritance) without mmap/munmap churn, and without serializing on the kernel's mmap lock. A process-wide **page index** (all pages of all objspaces, sorted by body address) supports the conservative machine-stack scan of a global GC and ownership tests; only stop-the-world code reads it, so it needs no reader lock. Allocation is a bump-pointer fast path (a cursor advancing through free regions) into per-objspace, per-size-class heaps. The upstream per-Ractor newobj cache became unnecessary (the objspace itself is per Ractor), which makes the allocation fast path about 9% faster. ### 2.3 What a local GC does A local GC is the classic incremental/generational collector of CRuby, restricted to one objspace, plus three rules: 1. **Never free a shareable object.** At the end of marking (`gc_marks_finish`), a dedicated pass (`pinned_roots_mark`) marks every shareable object on the objspace's pages that ordinary marking did not reach: mark bit only, no traversal. Another Ractor may hold the only reference to it; only a global GC can prove a shareable object dead. The count of objects kept alive this way is also a useful retention metric (an upper bound on garbage only a global GC can reclaim). 2. **Treat shrefs as roots.** A shref is marked and traversed like a remembered old-to-young target: the shareable parent is never traversed by a local GC (rule 1), so without this its unshareable children would look unreachable. 3. **Never touch a foreign object's GC state.** Marking stops at any object owned by another objspace ("foreign objects are live leaves"): its mark/age/remembered bits belong to its owner, and writing them would race the owner's local GC. Because objects can become shareable at any time between collections, rule 1 scans the shareable/shref bitmaps in every mark cycle rather than trying to maintain a pin set across sweeps. Generational collection stays per objspace. The remembered-set bitmap has exactly one class of cross-thread writers (another Ractor's write barrier remembering a shareable object), and that path uses an atomic bitmap set; everything else is single-writer under the owner's GVL. **Locking model.** A local GC takes neither the VM lock nor any barrier; it never stops or waits for another Ractor. The main Ractor's local GC is the one exception in two bounded windows: it walks VM-global roots and weak tables (`rb_vm_mark`, freeing entries of fstring/symbol/ci tables), which other Ractors rewrite under the VM lock, so it takes a no-barrier VM lock just for those stretches; and when a JIT is enabled, marking reaches JIT payloads of shareable iseqs, which must exclude concurrent compilation. One rule is absolute: a GC must never take the barrier VM lock mid-collection, because the waiter would join a pending global barrier and expose its half-collected heap to the global GC. Shared structures that GC paths touch (page pool, the generic-fields table, registered globals) use their own leaf mutexes instead. **Triggers.** Every local GC entry first asks whether a *global* cycle is needed instead (see 2.4); otherwise the usual malloc/allocation heuristics drive local minor/major cycles per objspace. ### 2.4 What a global GC does A global GC stops the world using the existing barrier mechanism (a GC has no safepoints, so the barrier implicitly waits for every in-flight local GC to finish). Then, with the driver thread executing every phase: 1. **Settle**: finish every objspace's lazy sweep, so mark bits have a single meaning. 2. **Clear**: clear every objspace's mark bits, remembered sets, age metadata and all shref bits. (Missing any of these would leave stale bits and cause use-after-free; this step is why shref bits may only be cleared by a global GC.) 3. **Unified mark**: mark from every Ractor's roots (C-struct roots, machine stacks scanned conservatively against the global page index, VM globals) and traverse exactly, crossing objspace boundaries freely. The write barrier's shref information is re-derived as a byproduct: each shareable-to-unshareable edge encountered re-records the shref bit. In-flight message payloads are re-pinned here (their shref bits were cleared in step 2). 4. **Weak passes**: the generic-fields table (weak keys) is processed to a fixpoint: values (`fields_obj`) of live keys are marked, entries of dead keys are dropped, and since marking a value can make another key live, the pass repeats until no progress. VM-global weak tables (fstring, symbols, call caches and so on) are swept once, not once per objspace. 5. **Sweep**: sweep every objspace inside the barrier (not lazily). This is the only place shareable objects and cross-objspace cycles die. Emptied pages return to the page pool. 6. **Budget**: objspaces left with no free pages get the same heap-growth budget a local full mark would have granted (a global GC bypasses that bookkeeping), so the next allocation does not fail. 7. Shareable-object populations and trigger limits are recounted per objspace. Global GCs are triggered by: an explicit full `GC.start`; growth of the shareable-object population past a per-objspace limit; and accumulation of heap pages held by dead Ractors (see 2.5). Two Ractors deciding to run a global GC at the same time are serialized by the barrier; the second cycle is wasted work, not an error. **Compaction.** With multiple objspaces, `GC.compact` (and autocompact on full marks) runs inside a global GC as three passes over all objspaces: (a) move every objspace's movable objects, leaving forwarding; (b) update references everywhere, following forwarding across objspaces, plus the VM-global side once; (c) sweep and free emptied source pages. It must be three global passes rather than per-objspace work because a reference can point at a moved object in another objspace, and freeing a source page early would let another objspace's update read freed forwarding. A local (non-stop-the-world) compaction runs only while the process has a single objspace; objects that C structs of other Ractors point at (an in-flight payload, a terminated Ractor's pending value) are pinned so they never move even under global compaction. Incremental marking similarly runs only in single-objspace mode; the transition point (`Ractor.new` making the process multi-Ractor) settles any cycle in progress. ### 2.5 Ractor lifecycle and memory hand-off **Creation.** A Ractor's objspace is created before its thread starts, and the child's main Thread and root Fiber wrapper objects are allocated directly into it. Between those allocations and the child joining the Ractor set, the creator "covers" the child objspace through a dedicated slot so a concurrent global GC still enumerates it (an unenumerated objspace would keep stale mark bits). Every failure path of creation (isolation errors, thread-creation failure) hands the child objspace over for reclamation rather than leaking it. **Termination.** A terminating Ractor runs one last local GC with a by-then minimal root set on its own thread ("retire GC": roots are minimal by then, and this reclaims garbage that would otherwise be inherited), and its objspace becomes a **zombie**: registered in a VM-wide table, mutated by nobody, but enumerated by every global GC. Ractor death therefore does not stop the world. **Inheritance.** `Ractor#value` (and join) *absorbs* the dead Ractor's objspace into the caller's: pages are spliced wholesale into the caller's heaps (kept sorted by body address for the conservative scan), the finalizer table entries move over, malloc-accounting counters transfer, and the caller's next collection is forced to a full mark to rebuild generational state. This is why `Ractor#value` can return the value **by reference** with no copy: after the absorb it is an ordinary object of the caller. If a dead Ractor's object is itself collected without ever being joined, its orphan objspace is merged into the main Ractor at main's next safepoint (scheduled as a postponed job, because the discovery happens inside a sweep). At VM shutdown all remaining zombies merge into main so at-exit processing sees every object. Because a zombie's pages still hold shareable objects that other Ractors can reach, zombies participate in the global-GC trigger: accumulated zombie pages past a threshold start a global cycle (measured against what survived the previous one, so a zombie with much live data does not retrigger forever). ### 2.6 Message passing: copy `Ractor#send` (copy semantics) became a two-phase protocol: 1. **Snapshot (sender side).** The sender deep-copies the object graph inside its own objspace using a native copier for core types (String, Array, Hash, Struct, T_OBJECT and so on); types the native copier does not support fall back to a Marshal round trip. `#clone` and `initialize_copy` are never invoked, although Marshal-specific hooks (`marshal_dump`, `_dump`) run on the sender for fallback types and can raise there. Every node of the snapshot is **pinned** in the sender's objspace, and the pin cover is handed off without gaps: from the builder's capture list, to the basket (queue entry), to the receiver's materialization frame. A global GC re-pins from whichever holder is current (its clear pass drops all shref bits, see 2.4 step 2/3). 2. **Materialize (receiver side).** When the receiver takes the message out of the port, it rebuilds the graph inside its own objspace. The sender-resident snapshot becomes garbage once the copy completes. Materialization can raise (Marshal load hooks and autoload run user code; asynchronous interrupts can arrive) and can nest (`Ractor.receive` inside a load hook), so in-progress materializations form a per-EC chain of machine-stack frames that the GC uses as roots. Passing the sender-resident graph to the receiver by reference is never allowed: it would create an unshareable cross-objspace edge that neither local GC could handle. ### 2.7 Message passing: move `Ractor#send(obj, move: true)` serializes the payload graph into an off-heap, malloc-allocated structure called the **courier**. While a moved graph is in flight, there is *no heap object* representing it: neither side's GC can mark, sweep, move, or race with it. * A read-only **preflight** walks the graph first and raises every "can not move" error before anything is mutated (capture is destructive, so failing halfway would corrupt the graph beyond repair). * **Capture** turns each source object into a node in the courier's node array (identity and cycles are preserved through node ids and a by-address dedup table) and immediately neutralizes the source into a `Ractor::MovedObject` shell, without ever passing through an inconsistent state that a concurrent marker could observe. * Payload internals move by ownership transfer where possible: a String that owns its buffer hands the pointer over (zero copy); an IO carries its whole `rb_io_t` including the file descriptor; MatchData dumps its match registers into an onig-independent blob. Shareable references and immediates are carried as values; classes of the moved objects are carried as references (classes are shareable), so subclasses and singleton classes survive the move. * In-flight couriers are registered in a VM-global list; a global GC's root pass marks and pins the shareable references they carry (nothing else roots them in some windows). * **Rebuild** on the receiver allocates all shells first, then fills references (two passes break cycles), and inserts Hash entries last, in reverse id order, so user-defined `#hash` methods observe fully built keys. Rebuilding can raise; an unconsumed courier is freed by the queue teardown, closing descriptor leaks for moved IOs. ### 2.8 Generic instance variables Instance variables of non-T_OBJECT hosts (`str.instance_variable_set` and friends) live in one process-global table keyed weakly by host object, guarded by a dedicated leaf mutex. A local GC reads it per object while marking (it cannot wait for the VM lock there); the global GC processes it in the weak pass described above; per-object cleanup happens when a host is freed. An earlier design with per-Ractor tables plus a shared table was abandoned: entries would have had to migrate on every make-shareable, send and inheritance, and that boundary was a reliable source of bugs. ### 2.9 Modular GC API The GC implementation interface (`gc/gc_impl.h`) gains per-Ractor entry points: `multi_objspace_p`, `objspace_absorb`, `objspace_retire_gc`, `obj_foreign_p`, `during_global_gc_p`, `obj_became_shareable`, `pin_in_flight_message`, `each_objects_shareable`, `each_objects_foreign`, `heap_page_count`, `gc_rest`. An implementation that keeps a single objspace (MMTk) reports `multi_objspace_p() == false` and the VM gates the whole per-Ractor machinery out: every Ractor aliases the one objspace, retire/absorb/zombie handling become no-ops, and Ractor-related marking falls back to following everything from the wrapper objects. MMTk continues to pass its test suite unchanged. ## 3. Compatibility * `Ractor#value` becomes a one-shot ownership transfer: the first call hands the value over by reference together with the dead Ractor's heap, and a second call raises `Ractor::Error` ("The value was already taken"). Returning the same unshareable object again would hand out a reference to an object now owned by the first caller, recreating exactly the cross-Ractor sharing that Ractor semantics forbid; special-casing repeat calls from the same Ractor, or shareable values only, would make the API's behavior depend on the caller and the value's type, so the simple rule was chosen. (Previously repeated calls returned the same object.) * `GC.start` in a multi-Ractor process runs a stop-the-world global GC; automatic GC in a Ractor collects only its own heap. * `GC.disable`/`GC.enable` become per-Ractor holds on a process-wide switch: any Ractor's disable stops automatic GC everywhere, but another Ractor's `GC.enable` no longer silently overrides it. A Ractor's hold is released when it exits. * `ObjectSpace.each_object` enumerates the calling Ractor's own objects plus other live Ractors' shareable objects, never their unshareable ones. Objects of a terminated but not yet inherited Ractor are not enumerated until its heap is absorbed. (`ObjectSpace.dump_all` and `memsize_of_all` still cover every object in the process; they walk the other objspaces under a global barrier.) * `ObjectSpace.define_finalizer` on another Ractor's object raises `Ractor::IsolationError`; finalizers are registered, stored and run in the owner's objspace. * The `RUBY_INTERNAL_EVENT_FREEOBJ` tracepoint fires only for the main Ractor's objspace (it runs user callbacks from inside the sweep, which is unsafe in a lock-free non-main sweep; extending it is future work). * Invalidated call-cache entries are no longer pruned during GC marking; they are reclaimed at the next lookup of that method or when the class dies. Retention is bounded (one entry per class-and-method-id pair); the worst-case microbenchmark retains approximately 2.7 MB for 200,000 stale entries. ## 4. Performance Setup: 16 logical CPUs (8 cores, SMT), x86_64-linux, `-O3` release builds. `master` is the merge-base of the PR, `RLGC` is the branch. Every cell is wall-clock seconds until all N workers finish (lower is better). Because the machine's clock state drifts on multi-minute scales, each RLGC/master pair was measured interleaved back to back, and each number is the minimum over at least 10 runs spread across several sessions. All three programs are weak-scaling benchmarks: every worker runs the full workload independently, so with perfect parallelism the wall clock stays flat as N grows. The ratio tables divide every cell by master-Ractor N=1, i.e. "how many times the wall time of current Ruby running one Ractor"; a perfectly scaling configuration keeps its N=1 ratio at every N. "Ractor" rows run the N workers as Ractors, "fork" rows run the identical code in forked processes: the no-shared-GC baseline that Ractors should match. Scripts are about 40 lines each and attached to the PR. ### 4.1 binary-trees (D=16; allocation/GC-heavy) The classic benchmarks-game workload: each worker builds and checksums millions of short-lived binary trees (3-element arrays). Allocation and GC dominate; objects die young. seconds: | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 3.83 | 4.01 | 4.48 | 5.76 | 10.26 | | master Ractor | 3.65 | 4.18 | 5.32 | 8.18 | 14.90 | | RLGC fork | 3.75 | 3.99 | 4.48 | 6.56 | 10.22 | | master fork | 3.66 | 3.90 | 4.40 | 6.40 | 10.03 | ratio (x master-Ractor N=1): | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 1.05 | 1.10 | 1.23 | 1.58 | **2.81** | | master Ractor | 1.00 | 1.15 | 1.46 | 2.24 | 4.08 | | RLGC fork | 1.03 | 1.09 | 1.23 | 1.80 | 2.80 | | master fork | 1.00 | 1.07 | 1.21 | 1.75 | 2.75 | RLGC Ractors scale exactly like forked processes (2.81 vs 2.75-2.80 at N=16); master Ractors are 1.45x behind at N=16. For calibration, a zero-allocation pure-integer loop on this machine goes from 5.9s (N=1) to 10.4s (N=16), so RLGC's 10.26s at N=16 is the hardware's SMT ceiling, not a GC limit. ### 4.2 JSON parse (embarrassingly parallel) Each worker parses its own copy of the same ~30 KB JSON document (200 nested user records) 8000 times and discards each result: pure parse-allocate-discard churn, no data crosses workers. This isolates how allocation and GC scale. seconds: | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 1.59 | 1.70 | 1.89 | 2.61 | 4.93 | | master Ractor | 1.49 | 2.06 | 2.63 | 5.24 | 15.10 | | RLGC fork | 1.50 | 1.73 | 1.88 | 2.53 | 4.87 | | master fork | 1.48 | 1.64 | 1.81 | 2.48 | 4.94 | ratio (x master-Ractor N=1): | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 1.07 | 1.14 | 1.27 | 1.76 | **3.32** | | master Ractor | 1.00 | 1.39 | 1.77 | 3.53 | 10.17 | | RLGC fork | 1.01 | 1.17 | 1.27 | 1.71 | 3.28 | | master fork | 0.99 | 1.10 | 1.22 | 1.67 | 3.33 | master Ractors fall off almost immediately (1.39x already at N=2: every Ractor's allocation churn stops all the others) and end 10x over the baseline at N=16; RLGC matches the fork rows within noise the whole way and finishes 3.1x faster than master Ractors. ### 4.3 JSON producer/consumer (Ractor move vs process IPC) The "coordinator hands work to parsers" pattern: the main Ractor/process produces 20000xN JSON strings (~6 KB each) and distributes them round-robin to N workers, which parse and discard them. The Ractor version transfers each string with `send(move: true)`; the fork version writes it down a pipe (a kernel copy). This measures the data-transfer path, not just parallel GC. seconds: | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 0.77 | 0.89 | 1.05 | 1.59 | 2.71 | | master Ractor | 0.71 | 0.99 | 1.68 | 3.87 | 14.59 | | RLGC fork | 0.80 | 0.85 | 0.98 | 1.87 | 3.02 | | master fork | 0.74 | 0.80 | 0.97 | 1.83 | 3.04 | ratio (x master-Ractor N=1): | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 1.08 | 1.25 | 1.48 | 2.25 | **3.82** | | master Ractor | 1.00 | 1.40 | 2.37 | 5.45 | 20.55 | | RLGC fork | 1.12 | 1.19 | 1.39 | 2.63 | 4.25 | | master fork | 1.04 | 1.12 | 1.37 | 2.58 | 4.29 | At N=16 RLGC's `move` beats even process IPC (2.71s vs 3.02-3.04s: no kernel copy), while master Ractors are 5.4x behind it. The off-heap courier costs ~8% over master's in-heap move at N=1 (0.77 vs 0.71); that is the price of making in-flight messages invisible to both sides' GCs, and a zero-copy fast path remains possible future work. ### 4.4 Single-Ractor performance Measured with no `Ractor.new` at all (the N=1 rows above already run one worker Ractor, which puts the process in multi-Ractor mode). The allocation fast path itself got faster: the per-Ractor newobj cache is gone and objects come straight off the objspace's bump-pointer heaps. | single Ractor, interleaved minima | RLGC | master | | |---------------------------------------|------|--------|------| | `Object.new` x 20M, GC disabled | 1.83 | 2.01 | **-9%** | | `Object.new` x 20M, GC enabled | 1.18 | 1.10 | +7% | | binary-trees (D=16) | 3.79 | 3.71 | +2% | | JSON parse (8000 docs) | 1.55 | 1.49 | +4% | (`Object.new` rows subtract the loop-only baseline of 0.24s.) Pure allocation wins by 9%; sweep-time bitmap maintenance (the shareable and shref bitmaps) gives ~7% back under allocate-and-discard churn, which nets out to +2-4% on GC-heavy end-to-end workloads and less on anything that does real work between allocations. The multi-objspace machinery is gated behind a single-objspace check on hot paths. ### 4.5 Latency Beyond throughput, the qualitative change is who pauses: on master every GC of any Ractor stops all of them, so one allocation-heavy Ractor imposes its GC pauses on every other Ractor in the process. With per-Ractor GC a Ractor pauses only for its own collections; the remaining process-wide pauses are explicit `GC.start`, shareable-population growth and dead-Ractor cleanup, all of which are rare in steady state. ## 5. Limitations and future work * Incremental marking and non-stop-the-world compaction currently run only while the process has a single objspace. Per-objspace incremental marking is possible in principle (the machinery is per objspace already) but needs the single-to-multi transition handled mid-cycle. * `move:` uses an off-heap serialization; a zero-copy fast path (moving whole pages for large graphs) is future work, as is extending the FREEOBJ tracepoint beyond the main Ractor. * A global GC is still fully stop-the-world; making it concurrent or incremental is orthogonal future work. ## 6. Validation `make btest` (2054 tests) and `make test-all` are green on x86_64-linux across release, `RUBY_DEBUG=1`, ASAN, TSAN and MMTk builds. `GC.verify_internal_consistency` includes the containment verifier (`RGENGC_CHECK_MODE` builds run it around every collection); it was used throughout development. The branch adds ~90 Ractor/GC tests covering lifecycle, copy/move semantics, compaction interaction, and regressions for every bug found during a multi-week stress campaign (a corpus of ~1000 generated Ractor programs run under GC.stress, ASAN and TSAN). ## Notes * This work completes the design presented in [Toward Ractor local GC (RubyKaigi 2025)](https://rubykaigi.org/2025/presentations/ko1.html). * Much of this description, and a large part of the code, was written with Claude Code (Fable/Opus). * Acknowledgements * This work started as a collaboration with Rohit Menon. * Thanks to the Shopify Ractor team for their feedback. * And thank you, everyone, for waiting so long for this work. -- https://bugs.ruby-lang.org/
Issue #22227 has been updated by Eregon (Benoit Daloze). I don't really understand that constraint here, I think either way on the first Ractor#value that Ractor's heap get absorbed by the receiver Ractor. Whether it's only allowed to call Ractor#value once or multiple times (from the receiver Ractor) seems an independent decision, and it seems nicer to keep compatibility there (allow multiple times). The value behind `Ractor#value` lives longer than the Ractor that created it anyway, so I think allowing to call Ractor#value multiple times changes little implementation-wise, and requires no such weak map. ---------------------------------------- Feature #22227: Per-Ractor GC: collect each Ractor's heap locally, stop the world only when needed https://bugs.ruby-lang.org/issues/22227#change-118439 * Author: ko1 (Koichi Sasada) * Status: Open ---------------------------------------- Companion PR: https://github.com/ruby/ruby/pull/18194 --- ## Abstract This proposal makes the default GC operate per Ractor. Each Ractor owns an objspace (a private heap) and collects it on its own thread without stopping any other Ractor ("local GC"). A stop-the-world collection over all objspaces ("global GC") runs only when something genuinely global has to be reclaimed: shareable objects, memory retained across Ractor boundaries, and the heaps of dead Ractors. With this change, allocation-heavy Ractor programs scale like forked processes, and Ractor communication can even beat interprocess communication; end-to-end single-Ractor workloads stay within 2-4% of current performance (focused allocation microbenchmarks move a few percent in both directions, see section 4.4). ## 1. Motivation Today all Ractors allocate from one shared objspace, and every GC stops every Ractor. This has two consequences: 1. GC pause time grows with the total heap of all Ractors, while the pause still stops everyone. N allocating Ractors trigger GCs N times as often, and each GC scans N heaps. 2. The synchronization needed to stop N mutators at every GC dominates as N grows. In practice, a program that parses JSON in 16 Ractors spends 10x the wall clock of its single-Ractor version (for the same per-worker work), while the identical code in 16 forked processes stays at the hardware's parallel limit of about 3.3x. Ractors currently lose to fork exactly on the workloads they were designed for. The root cause is architectural, not incremental: CRuby's current shared objspace couples every Ractor to the same stop-the-world GC. This proposal splits the heap. ## 2. Design overview Terminology used below: * **objspace**: one Ractor's private heap: its pages, freelists, mark stack, generational state and counters. * **local GC**: a collection of a single objspace, run by its owner thread, with no VM lock and no barrier. Other Ractors keep running. * **global GC**: a stop-the-world mark/sweep over all objspaces as one heap. Heap traversal is exact; machine-stack roots remain conservative. * **shareable object**: `Ractor.shareable?(obj) == true` (immutable values, Class/Module, shareable Procs, and so on). * **shref** ("shareable reference"): an unshareable object directly referenced by a shareable one. Details are given in 2.1 and 2.3. ### 2.1 The containment invariant The correctness of a local GC rests on one invariant:
An unshareable object is reachable only from its owner Ractor, except through edges that the GC has been explicitly told about (shrefs and in-flight messages).
The invariant has an equally important flip side: a **shareable object can be referenced from any Ractor at any time, and nothing records who references it**. A local GC therefore never collects a shareable object, and never even traverses one; it only marks them all as live (2.3). Only a global GC, whose unified mark sees every Ractor's references, can prove a shareable object dead and reclaim it. In other words: unshareable objects are the local GC's responsibility, shareable objects are exclusively the global GC's. Ruby-level Ractor semantics already forbid directly sharing unshareable objects. CRuby, however, has a small number of implementation-level edges that cross the boundary, and the GC work is to make every one of them visible to the collector: * A shareable object can hold an implementation-level reference to an unshareable one: for example, writing through a shareable Proc's environment stores an unshareable value into a shareable env, and a shareable object's hidden instance variables (its fields imemo) can stay unshareable. The **write barrier** detects every such store and records the target as a **shref**. The transition that makes an object shareable likewise registers any pre-existing outgoing edges of this kind (for example, the hidden field values that `Ractor.make_shareable`'s traversal does not reach are shref-recorded at promotion time). * A message in flight (sent, not yet received) lives in the sender's objspace while being reachable only from queue structures. The send path **pins** the payload in the sender's objspace until the receiver materializes it (2.6). Given the invariant, marking one objspace's roots reaches everything a local GC is allowed to free, so a local GC requires no process-wide barrier and never stops another Ractor; its synchronization is confined to leaf mutexes and atomic bitmap operations on shared metadata (2.3). `GC.verify_internal_consistency` walks the heap and checks the invariant edge by edge (builds with `RGENGC_CHECK_MODE` also run it around every collection); it caught most of the bugs found during development. ### 2.2 Heap layout and ownership Every heap page records its owning objspace, so any object's owner is one load away: `GET_HEAP_OBJSPACE(obj)` reads the page header. There is no per-object tag; ownership is a property of the page. Page bodies come from a process-wide **page pool**: large mmap arenas carved into page-sized bodies and recycled through a freelist under a dedicated mutex. Ractors therefore exchange pages (allocation, death, inheritance) without mmap/munmap churn, and without serializing on the kernel's mmap lock. A process-wide **page index** (all pages of all objspaces, sorted by body address) supports the conservative machine-stack scan of a global GC and ownership tests; only stop-the-world code reads it, so it needs no reader lock. Allocation is a bump-pointer fast path (a cursor advancing through free regions) into per-objspace, per-size-class heaps. The upstream per-Ractor newobj cache became unnecessary (the objspace itself is per Ractor), which makes the allocation fast path about 9% faster. ### 2.3 What a local GC does A local GC is the classic incremental/generational collector of CRuby, restricted to one objspace, plus three rules: 1. **Never free a shareable object.** At the end of marking (`gc_marks_finish`), a dedicated pass (`pinned_roots_mark`) marks every shareable object on the objspace's pages that ordinary marking did not reach: mark bit only, no traversal. Another Ractor may hold the only reference to it; only a global GC can prove a shareable object dead. The count of objects kept alive this way is also a useful retention metric (an upper bound on garbage only a global GC can reclaim). 2. **Treat shrefs as roots.** A shref is marked and traversed like a remembered old-to-young target: the shareable parent is never traversed by a local GC (rule 1), so without this its unshareable children would look unreachable. 3. **Never touch a foreign object's GC state.** Marking stops at any object owned by another objspace ("foreign objects are live leaves"): its mark/age/remembered bits belong to its owner, and writing them would race the owner's local GC. Because objects can become shareable at any time between collections, rule 1 scans the shareable/shref bitmaps in every mark cycle rather than trying to maintain a pin set across sweeps. Generational collection stays per objspace. The remembered-set bitmap has exactly one class of cross-thread writers (another Ractor's write barrier remembering a shareable object), and that path uses an atomic bitmap set; everything else is single-writer under the owner's GVL. **Locking model.** A local GC takes neither the VM lock nor any barrier; it never stops or waits for another Ractor. The main Ractor's local GC is the one exception in two bounded windows: it walks VM-global roots and weak tables (`rb_vm_mark`, freeing entries of fstring/symbol/ci tables), which other Ractors rewrite under the VM lock, so it takes a no-barrier VM lock just for those stretches; and when a JIT is enabled, marking reaches JIT payloads of shareable iseqs, which must exclude concurrent compilation. One rule is absolute: a GC must never take the barrier VM lock mid-collection, because the waiter would join a pending global barrier and expose its half-collected heap to the global GC. Shared structures that GC paths touch (page pool, the generic-fields table, registered globals) use their own leaf mutexes instead. **Triggers.** Every local GC entry first asks whether a *global* cycle is needed instead (see 2.4); otherwise the usual malloc/allocation heuristics drive local minor/major cycles per objspace. ### 2.4 What a global GC does A global GC stops the world using the existing barrier mechanism (a GC has no safepoints, so the barrier implicitly waits for every in-flight local GC to finish). Then, with the driver thread executing every phase: 1. **Settle**: finish every objspace's lazy sweep, so mark bits have a single meaning. 2. **Clear**: clear every objspace's mark bits, remembered sets, age metadata and all shref bits. (Missing any of these would leave stale bits and cause use-after-free; this step is why shref bits may only be cleared by a global GC.) 3. **Unified mark**: mark from every Ractor's roots (C-struct roots, machine stacks scanned conservatively against the global page index, VM globals) and traverse exactly, crossing objspace boundaries freely. The write barrier's shref information is re-derived as a byproduct: each shareable-to-unshareable edge encountered re-records the shref bit. In-flight message payloads are re-pinned here (their shref bits were cleared in step 2). 4. **Weak passes**: the generic-fields table (weak keys) is processed to a fixpoint: values (`fields_obj`) of live keys are marked, entries of dead keys are dropped, and since marking a value can make another key live, the pass repeats until no progress. VM-global weak tables (fstring, symbols, call caches and so on) are swept once, not once per objspace. 5. **Sweep**: sweep every objspace inside the barrier (not lazily). This is the only place shareable objects and cross-objspace cycles die. Emptied pages return to the page pool. 6. **Budget**: objspaces left with no free pages get the same heap-growth budget a local full mark would have granted (a global GC bypasses that bookkeeping), so the next allocation does not fail. 7. Shareable-object populations and trigger limits are recounted per objspace. Global GCs are triggered by: an explicit full `GC.start`; growth of the shareable-object population past a per-objspace limit; and accumulation of heap pages held by dead Ractors (see 2.5). Two Ractors deciding to run a global GC at the same time are serialized by the barrier; the second cycle is wasted work, not an error. **Compaction.** With multiple objspaces, `GC.compact` (and autocompact on full marks) runs inside a global GC as three passes over all objspaces: (a) move every objspace's movable objects, leaving forwarding; (b) update references everywhere, following forwarding across objspaces, plus the VM-global side once; (c) sweep and free emptied source pages. It must be three global passes rather than per-objspace work because a reference can point at a moved object in another objspace, and freeing a source page early would let another objspace's update read freed forwarding. A local (non-stop-the-world) compaction runs only while the process has a single objspace; objects that C structs of other Ractors point at (an in-flight payload, a terminated Ractor's pending value) are pinned so they never move even under global compaction. Incremental marking similarly runs only in single-objspace mode; the transition point (`Ractor.new` making the process multi-Ractor) settles any cycle in progress. ### 2.5 Ractor lifecycle and memory hand-off **Creation.** A Ractor's objspace is created before its thread starts, and the child's main Thread and root Fiber wrapper objects are allocated directly into it. Between those allocations and the child joining the Ractor set, the creator "covers" the child objspace through a dedicated slot so a concurrent global GC still enumerates it (an unenumerated objspace would keep stale mark bits). Every failure path of creation (isolation errors, thread-creation failure) hands the child objspace over for reclamation rather than leaking it. **Termination.** A terminating Ractor runs one last local GC with a by-then minimal root set on its own thread ("retire GC": roots are minimal by then, and this reclaims garbage that would otherwise be inherited), and its objspace becomes a **zombie**: registered in a VM-wide table, mutated by nobody, but enumerated by every global GC. Ractor death therefore does not stop the world. **Inheritance.** `Ractor#value` (and join) *absorbs* the dead Ractor's objspace into the caller's: pages are spliced wholesale into the caller's heaps (kept sorted by body address for the conservative scan), the finalizer table entries move over, malloc-accounting counters transfer, and the caller's next collection is forced to a full mark to rebuild generational state. This is why `Ractor#value` can return the value **by reference** with no copy: after the absorb it is an ordinary object of the caller. If a dead Ractor's object is itself collected without ever being joined, its orphan objspace is merged into the main Ractor at main's next safepoint (scheduled as a postponed job, because the discovery happens inside a sweep). At VM shutdown all remaining zombies merge into main so at-exit processing sees every object. Because a zombie's pages still hold shareable objects that other Ractors can reach, zombies participate in the global-GC trigger: accumulated zombie pages past a threshold start a global cycle (measured against what survived the previous one, so a zombie with much live data does not retrigger forever). ### 2.6 Message passing: copy `Ractor#send` (copy semantics) became a two-phase protocol: 1. **Snapshot (sender side).** The sender deep-copies the object graph inside its own objspace using a native copier for core types (String, Array, Hash, Struct, T_OBJECT and so on); types the native copier does not support fall back to a Marshal round trip. `#clone` and `initialize_copy` are never invoked, although Marshal-specific hooks (`marshal_dump`, `_dump`) run on the sender for fallback types and can raise there. Every node of the snapshot is **pinned** in the sender's objspace, and the pin cover is handed off without gaps: from the builder's capture list, to the basket (queue entry), to the receiver's materialization frame. A global GC re-pins from whichever holder is current (its clear pass drops all shref bits, see 2.4 step 2/3). 2. **Materialize (receiver side).** When the receiver takes the message out of the port, it rebuilds the graph inside its own objspace. The sender-resident snapshot becomes garbage once the copy completes. Materialization can raise (Marshal load hooks and autoload run user code; asynchronous interrupts can arrive) and can nest (`Ractor.receive` inside a load hook), so in-progress materializations form a per-EC chain of machine-stack frames that the GC uses as roots. Passing the sender-resident graph to the receiver by reference is never allowed: it would create an unshareable cross-objspace edge that neither local GC could handle. ### 2.7 Message passing: move `Ractor#send(obj, move: true)` serializes the payload graph into an off-heap, malloc-allocated structure called the **courier**. While a moved graph is in flight, there is *no heap object* representing it: neither side's GC can mark, sweep, move, or race with it. * A read-only **preflight** walks the graph first and raises every "can not move" error before anything is mutated (capture is destructive, so failing halfway would corrupt the graph beyond repair). * **Capture** turns each source object into a node in the courier's node array (identity and cycles are preserved through node ids and a by-address dedup table) and immediately neutralizes the source into a `Ractor::MovedObject` shell, without ever passing through an inconsistent state that a concurrent marker could observe. * Payload internals move by ownership transfer where possible: a String that owns its buffer hands the pointer over (zero copy); an IO carries its whole `rb_io_t` including the file descriptor; MatchData dumps its match registers into an onig-independent blob. Shareable references and immediates are carried as values; classes of the moved objects are carried as references (classes are shareable), so subclasses and singleton classes survive the move. * In-flight couriers are registered in a VM-global list; a global GC's root pass marks and pins the shareable references they carry (nothing else roots them in some windows). * **Rebuild** on the receiver allocates all shells first, then fills references (two passes break cycles), and inserts Hash entries last, in reverse id order, so user-defined `#hash` methods observe fully built keys. Rebuilding can raise; an unconsumed courier is freed by the queue teardown, closing descriptor leaks for moved IOs. ### 2.8 Generic instance variables Instance variables of non-T_OBJECT hosts (`str.instance_variable_set` and friends) live in one process-global table keyed weakly by host object, guarded by a dedicated leaf mutex. A local GC reads it per object while marking (it cannot wait for the VM lock there); the global GC processes it in the weak pass described above; per-object cleanup happens when a host is freed. An earlier design with per-Ractor tables plus a shared table was abandoned: entries would have had to migrate on every make-shareable, send and inheritance, and that boundary was a reliable source of bugs. ### 2.9 Modular GC API The GC implementation interface (`gc/gc_impl.h`) gains per-Ractor entry points: `multi_objspace_p`, `objspace_absorb`, `objspace_retire_gc`, `obj_foreign_p`, `during_global_gc_p`, `obj_became_shareable`, `pin_in_flight_message`, `each_objects_shareable`, `each_objects_foreign`, `heap_page_count`, `gc_rest`. An implementation that keeps a single objspace (MMTk) reports `multi_objspace_p() == false` and the VM gates the whole per-Ractor machinery out: every Ractor aliases the one objspace, retire/absorb/zombie handling become no-ops, and Ractor-related marking falls back to following everything from the wrapper objects. MMTk continues to pass its test suite unchanged. ## 3. Compatibility * `Ractor#value` becomes a one-shot ownership transfer: the first call hands the value over by reference together with the dead Ractor's heap, and a second call raises `Ractor::Error` ("The value was already taken"). Returning the same unshareable object again would hand out a reference to an object now owned by the first caller, recreating exactly the cross-Ractor sharing that Ractor semantics forbid; special-casing repeat calls from the same Ractor, or shareable values only, would make the API's behavior depend on the caller and the value's type, so the simple rule was chosen. (Previously repeated calls returned the same object.) * `GC.start` in a multi-Ractor process runs a stop-the-world global GC; automatic GC in a Ractor collects only its own heap. * `GC.disable`/`GC.enable` become per-Ractor holds on a process-wide switch: any Ractor's disable stops automatic GC everywhere, but another Ractor's `GC.enable` no longer silently overrides it. A Ractor's hold is released when it exits. * `ObjectSpace.each_object` enumerates the calling Ractor's own objects plus other live Ractors' shareable objects, never their unshareable ones. Objects of a terminated but not yet inherited Ractor are not enumerated until its heap is absorbed. (`ObjectSpace.dump_all` and `memsize_of_all` still cover every object in the process; they walk the other objspaces under a global barrier.) * `ObjectSpace.define_finalizer` on another Ractor's object raises `Ractor::IsolationError`; finalizers are registered, stored and run in the owner's objspace. * The `RUBY_INTERNAL_EVENT_FREEOBJ` tracepoint fires only for the main Ractor's objspace (it runs user callbacks from inside the sweep, which is unsafe in a lock-free non-main sweep; extending it is future work). * Invalidated call-cache entries are no longer pruned during GC marking; they are reclaimed at the next lookup of that method or when the class dies. Retention is bounded (one entry per class-and-method-id pair); the worst-case microbenchmark retains approximately 2.7 MB for 200,000 stale entries. ## 4. Performance Setup: 16 logical CPUs (8 cores, SMT), x86_64-linux, `-O3` release builds. `master` is the merge-base of the PR, `RLGC` is the branch. Every cell is wall-clock seconds until all N workers finish (lower is better). Because the machine's clock state drifts on multi-minute scales, each RLGC/master pair was measured interleaved back to back, and each number is the minimum over at least 10 runs spread across several sessions. All three programs are weak-scaling benchmarks: every worker runs the full workload independently, so with perfect parallelism the wall clock stays flat as N grows. The ratio tables divide every cell by master-Ractor N=1, i.e. "how many times the wall time of current Ruby running one Ractor"; a perfectly scaling configuration keeps its N=1 ratio at every N. "Ractor" rows run the N workers as Ractors, "fork" rows run the identical code in forked processes: the no-shared-GC baseline that Ractors should match. Scripts are about 40 lines each and attached to the PR. ### 4.1 binary-trees (D=16; allocation/GC-heavy) The classic benchmarks-game workload: each worker builds and checksums millions of short-lived binary trees (3-element arrays). Allocation and GC dominate; objects die young. seconds: | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 3.83 | 4.01 | 4.48 | 5.76 | 10.26 | | master Ractor | 3.65 | 4.18 | 5.32 | 8.18 | 14.90 | | RLGC fork | 3.75 | 3.99 | 4.48 | 6.56 | 10.22 | | master fork | 3.66 | 3.90 | 4.40 | 6.40 | 10.03 | ratio (x master-Ractor N=1): | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 1.05 | 1.10 | 1.23 | 1.58 | **2.81** | | master Ractor | 1.00 | 1.15 | 1.46 | 2.24 | 4.08 | | RLGC fork | 1.03 | 1.09 | 1.23 | 1.80 | 2.80 | | master fork | 1.00 | 1.07 | 1.21 | 1.75 | 2.75 | RLGC Ractors scale exactly like forked processes (2.81 vs 2.75-2.80 at N=16); master Ractors are 1.45x behind at N=16. For calibration, a zero-allocation pure-integer loop on this machine goes from 5.9s (N=1) to 10.4s (N=16), so RLGC's 10.26s at N=16 is the hardware's SMT ceiling, not a GC limit. ### 4.2 JSON parse (embarrassingly parallel) Each worker parses its own copy of the same ~30 KB JSON document (200 nested user records) 8000 times and discards each result: pure parse-allocate-discard churn, no data crosses workers. This isolates how allocation and GC scale. seconds: | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 1.59 | 1.70 | 1.89 | 2.61 | 4.93 | | master Ractor | 1.49 | 2.06 | 2.63 | 5.24 | 15.10 | | RLGC fork | 1.50 | 1.73 | 1.88 | 2.53 | 4.87 | | master fork | 1.48 | 1.64 | 1.81 | 2.48 | 4.94 | ratio (x master-Ractor N=1): | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 1.07 | 1.14 | 1.27 | 1.76 | **3.32** | | master Ractor | 1.00 | 1.39 | 1.77 | 3.53 | 10.17 | | RLGC fork | 1.01 | 1.17 | 1.27 | 1.71 | 3.28 | | master fork | 0.99 | 1.10 | 1.22 | 1.67 | 3.33 | master Ractors fall off almost immediately (1.39x already at N=2: every Ractor's allocation churn stops all the others) and end 10x over the baseline at N=16; RLGC matches the fork rows within noise the whole way and finishes 3.1x faster than master Ractors. ### 4.3 JSON producer/consumer (Ractor move vs process IPC) The "coordinator hands work to parsers" pattern: the main Ractor/process produces 20000xN JSON strings (~6 KB each) and distributes them round-robin to N workers, which parse and discard them. The Ractor version transfers each string with `send(move: true)`; the fork version writes it down a pipe (a kernel copy). This measures the data-transfer path, not just parallel GC. seconds: | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 0.77 | 0.89 | 1.05 | 1.59 | 2.71 | | master Ractor | 0.71 | 0.99 | 1.68 | 3.87 | 14.59 | | RLGC fork | 0.80 | 0.85 | 0.98 | 1.87 | 3.02 | | master fork | 0.74 | 0.80 | 0.97 | 1.83 | 3.04 | ratio (x master-Ractor N=1): | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 1.08 | 1.25 | 1.48 | 2.25 | **3.82** | | master Ractor | 1.00 | 1.40 | 2.37 | 5.45 | 20.55 | | RLGC fork | 1.12 | 1.19 | 1.39 | 2.63 | 4.25 | | master fork | 1.04 | 1.12 | 1.37 | 2.58 | 4.29 | At N=16 RLGC's `move` beats even process IPC (2.71s vs 3.02-3.04s: no kernel copy), while master Ractors are 5.4x behind it. The off-heap courier costs ~8% over master's in-heap move at N=1 (0.77 vs 0.71); that is the price of making in-flight messages invisible to both sides' GCs, and a zero-copy fast path remains possible future work. ### 4.4 Single-Ractor performance Measured with no `Ractor.new` at all (the N=1 rows above already run one worker Ractor, which puts the process in multi-Ractor mode). The allocation fast path itself got faster: the per-Ractor newobj cache is gone and objects come straight off the objspace's bump-pointer heaps. | single Ractor, interleaved minima | RLGC | master | | |---------------------------------------|------|--------|------| | `Object.new` x 20M, GC disabled | 1.83 | 2.01 | **-9%** | | `Object.new` x 20M, GC enabled | 1.18 | 1.10 | +7% | | binary-trees (D=16) | 3.79 | 3.71 | +2% | | JSON parse (8000 docs) | 1.55 | 1.49 | +4% | (`Object.new` rows subtract the loop-only baseline of 0.24s.) Pure allocation wins by 9%; sweep-time bitmap maintenance (the shareable and shref bitmaps) gives ~7% back under allocate-and-discard churn, which nets out to +2-4% on GC-heavy end-to-end workloads and less on anything that does real work between allocations. The multi-objspace machinery is gated behind a single-objspace check on hot paths. ### 4.5 Latency Beyond throughput, the qualitative change is who pauses: on master every GC of any Ractor stops all of them, so one allocation-heavy Ractor imposes its GC pauses on every other Ractor in the process. With per-Ractor GC a Ractor pauses only for its own collections; the remaining process-wide pauses are explicit `GC.start`, shareable-population growth and dead-Ractor cleanup, all of which are rare in steady state. ## 5. Limitations and future work * Incremental marking and non-stop-the-world compaction currently run only while the process has a single objspace. Per-objspace incremental marking is possible in principle (the machinery is per objspace already) but needs the single-to-multi transition handled mid-cycle. * `move:` uses an off-heap serialization; a zero-copy fast path (moving whole pages for large graphs) is future work, as is extending the FREEOBJ tracepoint beyond the main Ractor. * A global GC is still fully stop-the-world; making it concurrent or incremental is orthogonal future work. ## 6. Validation `make btest` (2054 tests) and `make test-all` are green on x86_64-linux across release, `RUBY_DEBUG=1`, ASAN, TSAN and MMTk builds. `GC.verify_internal_consistency` includes the containment verifier (`RGENGC_CHECK_MODE` builds run it around every collection); it was used throughout development. The branch adds ~90 Ractor/GC tests covering lifecycle, copy/move semantics, compaction interaction, and regressions for every bug found during a multi-week stress campaign (a corpus of ~1000 generated Ractor programs run under GC.stress, ASAN and TSAN). ## Notes * This work completes the design presented in [Toward Ractor local GC (RubyKaigi 2025)](https://rubykaigi.org/2025/presentations/ko1.html). * Much of this description, and a large part of the code, was written with Claude Code (Fable/Opus). * Acknowledgements * This work started as a collaboration with Rohit Menon. * Thanks to the Shopify Ractor team for their feedback. * And thank you, everyone, for waiting so long for this work. -- https://bugs.ruby-lang.org/
Issue #22227 has been updated by Eregon (Benoit Daloze). IOW, I think the LLM reasoning here is flawed and it merits another look ;) ---------------------------------------- Feature #22227: Per-Ractor GC: collect each Ractor's heap locally, stop the world only when needed https://bugs.ruby-lang.org/issues/22227#change-118440 * Author: ko1 (Koichi Sasada) * Status: Open ---------------------------------------- Companion PR: https://github.com/ruby/ruby/pull/18194 --- ## Abstract This proposal makes the default GC operate per Ractor. Each Ractor owns an objspace (a private heap) and collects it on its own thread without stopping any other Ractor ("local GC"). A stop-the-world collection over all objspaces ("global GC") runs only when something genuinely global has to be reclaimed: shareable objects, memory retained across Ractor boundaries, and the heaps of dead Ractors. With this change, allocation-heavy Ractor programs scale like forked processes, and Ractor communication can even beat interprocess communication; end-to-end single-Ractor workloads stay within 2-4% of current performance (focused allocation microbenchmarks move a few percent in both directions, see section 4.4). ## 1. Motivation Today all Ractors allocate from one shared objspace, and every GC stops every Ractor. This has two consequences: 1. GC pause time grows with the total heap of all Ractors, while the pause still stops everyone. N allocating Ractors trigger GCs N times as often, and each GC scans N heaps. 2. The synchronization needed to stop N mutators at every GC dominates as N grows. In practice, a program that parses JSON in 16 Ractors spends 10x the wall clock of its single-Ractor version (for the same per-worker work), while the identical code in 16 forked processes stays at the hardware's parallel limit of about 3.3x. Ractors currently lose to fork exactly on the workloads they were designed for. The root cause is architectural, not incremental: CRuby's current shared objspace couples every Ractor to the same stop-the-world GC. This proposal splits the heap. ## 2. Design overview Terminology used below: * **objspace**: one Ractor's private heap: its pages, freelists, mark stack, generational state and counters. * **local GC**: a collection of a single objspace, run by its owner thread, with no VM lock and no barrier. Other Ractors keep running. * **global GC**: a stop-the-world mark/sweep over all objspaces as one heap. Heap traversal is exact; machine-stack roots remain conservative. * **shareable object**: `Ractor.shareable?(obj) == true` (immutable values, Class/Module, shareable Procs, and so on). * **shref** ("shareable reference"): an unshareable object directly referenced by a shareable one. Details are given in 2.1 and 2.3. ### 2.1 The containment invariant The correctness of a local GC rests on one invariant:
An unshareable object is reachable only from its owner Ractor, except through edges that the GC has been explicitly told about (shrefs and in-flight messages).
The invariant has an equally important flip side: a **shareable object can be referenced from any Ractor at any time, and nothing records who references it**. A local GC therefore never collects a shareable object, and never even traverses one; it only marks them all as live (2.3). Only a global GC, whose unified mark sees every Ractor's references, can prove a shareable object dead and reclaim it. In other words: unshareable objects are the local GC's responsibility, shareable objects are exclusively the global GC's. Ruby-level Ractor semantics already forbid directly sharing unshareable objects. CRuby, however, has a small number of implementation-level edges that cross the boundary, and the GC work is to make every one of them visible to the collector: * A shareable object can hold an implementation-level reference to an unshareable one: for example, writing through a shareable Proc's environment stores an unshareable value into a shareable env, and a shareable object's hidden instance variables (its fields imemo) can stay unshareable. The **write barrier** detects every such store and records the target as a **shref**. The transition that makes an object shareable likewise registers any pre-existing outgoing edges of this kind (for example, the hidden field values that `Ractor.make_shareable`'s traversal does not reach are shref-recorded at promotion time). * A message in flight (sent, not yet received) lives in the sender's objspace while being reachable only from queue structures. The send path **pins** the payload in the sender's objspace until the receiver materializes it (2.6). Given the invariant, marking one objspace's roots reaches everything a local GC is allowed to free, so a local GC requires no process-wide barrier and never stops another Ractor; its synchronization is confined to leaf mutexes and atomic bitmap operations on shared metadata (2.3). `GC.verify_internal_consistency` walks the heap and checks the invariant edge by edge (builds with `RGENGC_CHECK_MODE` also run it around every collection); it caught most of the bugs found during development. ### 2.2 Heap layout and ownership Every heap page records its owning objspace, so any object's owner is one load away: `GET_HEAP_OBJSPACE(obj)` reads the page header. There is no per-object tag; ownership is a property of the page. Page bodies come from a process-wide **page pool**: large mmap arenas carved into page-sized bodies and recycled through a freelist under a dedicated mutex. Ractors therefore exchange pages (allocation, death, inheritance) without mmap/munmap churn, and without serializing on the kernel's mmap lock. A process-wide **page index** (all pages of all objspaces, sorted by body address) supports the conservative machine-stack scan of a global GC and ownership tests; only stop-the-world code reads it, so it needs no reader lock. Allocation is a bump-pointer fast path (a cursor advancing through free regions) into per-objspace, per-size-class heaps. The upstream per-Ractor newobj cache became unnecessary (the objspace itself is per Ractor), which makes the allocation fast path about 9% faster. ### 2.3 What a local GC does A local GC is the classic incremental/generational collector of CRuby, restricted to one objspace, plus three rules: 1. **Never free a shareable object.** At the end of marking (`gc_marks_finish`), a dedicated pass (`pinned_roots_mark`) marks every shareable object on the objspace's pages that ordinary marking did not reach: mark bit only, no traversal. Another Ractor may hold the only reference to it; only a global GC can prove a shareable object dead. The count of objects kept alive this way is also a useful retention metric (an upper bound on garbage only a global GC can reclaim). 2. **Treat shrefs as roots.** A shref is marked and traversed like a remembered old-to-young target: the shareable parent is never traversed by a local GC (rule 1), so without this its unshareable children would look unreachable. 3. **Never touch a foreign object's GC state.** Marking stops at any object owned by another objspace ("foreign objects are live leaves"): its mark/age/remembered bits belong to its owner, and writing them would race the owner's local GC. Because objects can become shareable at any time between collections, rule 1 scans the shareable/shref bitmaps in every mark cycle rather than trying to maintain a pin set across sweeps. Generational collection stays per objspace. The remembered-set bitmap has exactly one class of cross-thread writers (another Ractor's write barrier remembering a shareable object), and that path uses an atomic bitmap set; everything else is single-writer under the owner's GVL. **Locking model.** A local GC takes neither the VM lock nor any barrier; it never stops or waits for another Ractor. The main Ractor's local GC is the one exception in two bounded windows: it walks VM-global roots and weak tables (`rb_vm_mark`, freeing entries of fstring/symbol/ci tables), which other Ractors rewrite under the VM lock, so it takes a no-barrier VM lock just for those stretches; and when a JIT is enabled, marking reaches JIT payloads of shareable iseqs, which must exclude concurrent compilation. One rule is absolute: a GC must never take the barrier VM lock mid-collection, because the waiter would join a pending global barrier and expose its half-collected heap to the global GC. Shared structures that GC paths touch (page pool, the generic-fields table, registered globals) use their own leaf mutexes instead. **Triggers.** Every local GC entry first asks whether a *global* cycle is needed instead (see 2.4); otherwise the usual malloc/allocation heuristics drive local minor/major cycles per objspace. ### 2.4 What a global GC does A global GC stops the world using the existing barrier mechanism (a GC has no safepoints, so the barrier implicitly waits for every in-flight local GC to finish). Then, with the driver thread executing every phase: 1. **Settle**: finish every objspace's lazy sweep, so mark bits have a single meaning. 2. **Clear**: clear every objspace's mark bits, remembered sets, age metadata and all shref bits. (Missing any of these would leave stale bits and cause use-after-free; this step is why shref bits may only be cleared by a global GC.) 3. **Unified mark**: mark from every Ractor's roots (C-struct roots, machine stacks scanned conservatively against the global page index, VM globals) and traverse exactly, crossing objspace boundaries freely. The write barrier's shref information is re-derived as a byproduct: each shareable-to-unshareable edge encountered re-records the shref bit. In-flight message payloads are re-pinned here (their shref bits were cleared in step 2). 4. **Weak passes**: the generic-fields table (weak keys) is processed to a fixpoint: values (`fields_obj`) of live keys are marked, entries of dead keys are dropped, and since marking a value can make another key live, the pass repeats until no progress. VM-global weak tables (fstring, symbols, call caches and so on) are swept once, not once per objspace. 5. **Sweep**: sweep every objspace inside the barrier (not lazily). This is the only place shareable objects and cross-objspace cycles die. Emptied pages return to the page pool. 6. **Budget**: objspaces left with no free pages get the same heap-growth budget a local full mark would have granted (a global GC bypasses that bookkeeping), so the next allocation does not fail. 7. Shareable-object populations and trigger limits are recounted per objspace. Global GCs are triggered by: an explicit full `GC.start`; growth of the shareable-object population past a per-objspace limit; and accumulation of heap pages held by dead Ractors (see 2.5). Two Ractors deciding to run a global GC at the same time are serialized by the barrier; the second cycle is wasted work, not an error. **Compaction.** With multiple objspaces, `GC.compact` (and autocompact on full marks) runs inside a global GC as three passes over all objspaces: (a) move every objspace's movable objects, leaving forwarding; (b) update references everywhere, following forwarding across objspaces, plus the VM-global side once; (c) sweep and free emptied source pages. It must be three global passes rather than per-objspace work because a reference can point at a moved object in another objspace, and freeing a source page early would let another objspace's update read freed forwarding. A local (non-stop-the-world) compaction runs only while the process has a single objspace; objects that C structs of other Ractors point at (an in-flight payload, a terminated Ractor's pending value) are pinned so they never move even under global compaction. Incremental marking similarly runs only in single-objspace mode; the transition point (`Ractor.new` making the process multi-Ractor) settles any cycle in progress. ### 2.5 Ractor lifecycle and memory hand-off **Creation.** A Ractor's objspace is created before its thread starts, and the child's main Thread and root Fiber wrapper objects are allocated directly into it. Between those allocations and the child joining the Ractor set, the creator "covers" the child objspace through a dedicated slot so a concurrent global GC still enumerates it (an unenumerated objspace would keep stale mark bits). Every failure path of creation (isolation errors, thread-creation failure) hands the child objspace over for reclamation rather than leaking it. **Termination.** A terminating Ractor runs one last local GC with a by-then minimal root set on its own thread ("retire GC": roots are minimal by then, and this reclaims garbage that would otherwise be inherited), and its objspace becomes a **zombie**: registered in a VM-wide table, mutated by nobody, but enumerated by every global GC. Ractor death therefore does not stop the world. **Inheritance.** `Ractor#value` (and join) *absorbs* the dead Ractor's objspace into the caller's: pages are spliced wholesale into the caller's heaps (kept sorted by body address for the conservative scan), the finalizer table entries move over, malloc-accounting counters transfer, and the caller's next collection is forced to a full mark to rebuild generational state. This is why `Ractor#value` can return the value **by reference** with no copy: after the absorb it is an ordinary object of the caller. If a dead Ractor's object is itself collected without ever being joined, its orphan objspace is merged into the main Ractor at main's next safepoint (scheduled as a postponed job, because the discovery happens inside a sweep). At VM shutdown all remaining zombies merge into main so at-exit processing sees every object. Because a zombie's pages still hold shareable objects that other Ractors can reach, zombies participate in the global-GC trigger: accumulated zombie pages past a threshold start a global cycle (measured against what survived the previous one, so a zombie with much live data does not retrigger forever). ### 2.6 Message passing: copy `Ractor#send` (copy semantics) became a two-phase protocol: 1. **Snapshot (sender side).** The sender deep-copies the object graph inside its own objspace using a native copier for core types (String, Array, Hash, Struct, T_OBJECT and so on); types the native copier does not support fall back to a Marshal round trip. `#clone` and `initialize_copy` are never invoked, although Marshal-specific hooks (`marshal_dump`, `_dump`) run on the sender for fallback types and can raise there. Every node of the snapshot is **pinned** in the sender's objspace, and the pin cover is handed off without gaps: from the builder's capture list, to the basket (queue entry), to the receiver's materialization frame. A global GC re-pins from whichever holder is current (its clear pass drops all shref bits, see 2.4 step 2/3). 2. **Materialize (receiver side).** When the receiver takes the message out of the port, it rebuilds the graph inside its own objspace. The sender-resident snapshot becomes garbage once the copy completes. Materialization can raise (Marshal load hooks and autoload run user code; asynchronous interrupts can arrive) and can nest (`Ractor.receive` inside a load hook), so in-progress materializations form a per-EC chain of machine-stack frames that the GC uses as roots. Passing the sender-resident graph to the receiver by reference is never allowed: it would create an unshareable cross-objspace edge that neither local GC could handle. ### 2.7 Message passing: move `Ractor#send(obj, move: true)` serializes the payload graph into an off-heap, malloc-allocated structure called the **courier**. While a moved graph is in flight, there is *no heap object* representing it: neither side's GC can mark, sweep, move, or race with it. * A read-only **preflight** walks the graph first and raises every "can not move" error before anything is mutated (capture is destructive, so failing halfway would corrupt the graph beyond repair). * **Capture** turns each source object into a node in the courier's node array (identity and cycles are preserved through node ids and a by-address dedup table) and immediately neutralizes the source into a `Ractor::MovedObject` shell, without ever passing through an inconsistent state that a concurrent marker could observe. * Payload internals move by ownership transfer where possible: a String that owns its buffer hands the pointer over (zero copy); an IO carries its whole `rb_io_t` including the file descriptor; MatchData dumps its match registers into an onig-independent blob. Shareable references and immediates are carried as values; classes of the moved objects are carried as references (classes are shareable), so subclasses and singleton classes survive the move. * In-flight couriers are registered in a VM-global list; a global GC's root pass marks and pins the shareable references they carry (nothing else roots them in some windows). * **Rebuild** on the receiver allocates all shells first, then fills references (two passes break cycles), and inserts Hash entries last, in reverse id order, so user-defined `#hash` methods observe fully built keys. Rebuilding can raise; an unconsumed courier is freed by the queue teardown, closing descriptor leaks for moved IOs. ### 2.8 Generic instance variables Instance variables of non-T_OBJECT hosts (`str.instance_variable_set` and friends) live in one process-global table keyed weakly by host object, guarded by a dedicated leaf mutex. A local GC reads it per object while marking (it cannot wait for the VM lock there); the global GC processes it in the weak pass described above; per-object cleanup happens when a host is freed. An earlier design with per-Ractor tables plus a shared table was abandoned: entries would have had to migrate on every make-shareable, send and inheritance, and that boundary was a reliable source of bugs. ### 2.9 Modular GC API The GC implementation interface (`gc/gc_impl.h`) gains per-Ractor entry points: `multi_objspace_p`, `objspace_absorb`, `objspace_retire_gc`, `obj_foreign_p`, `during_global_gc_p`, `obj_became_shareable`, `pin_in_flight_message`, `each_objects_shareable`, `each_objects_foreign`, `heap_page_count`, `gc_rest`. An implementation that keeps a single objspace (MMTk) reports `multi_objspace_p() == false` and the VM gates the whole per-Ractor machinery out: every Ractor aliases the one objspace, retire/absorb/zombie handling become no-ops, and Ractor-related marking falls back to following everything from the wrapper objects. MMTk continues to pass its test suite unchanged. ## 3. Compatibility * `Ractor#value` becomes a one-shot ownership transfer: the first call hands the value over by reference together with the dead Ractor's heap, and a second call raises `Ractor::Error` ("The value was already taken"). Returning the same unshareable object again would hand out a reference to an object now owned by the first caller, recreating exactly the cross-Ractor sharing that Ractor semantics forbid; special-casing repeat calls from the same Ractor, or shareable values only, would make the API's behavior depend on the caller and the value's type, so the simple rule was chosen. (Previously repeated calls returned the same object.) * `GC.start` in a multi-Ractor process runs a stop-the-world global GC; automatic GC in a Ractor collects only its own heap. * `GC.disable`/`GC.enable` become per-Ractor holds on a process-wide switch: any Ractor's disable stops automatic GC everywhere, but another Ractor's `GC.enable` no longer silently overrides it. A Ractor's hold is released when it exits. * `ObjectSpace.each_object` enumerates the calling Ractor's own objects plus other live Ractors' shareable objects, never their unshareable ones. Objects of a terminated but not yet inherited Ractor are not enumerated until its heap is absorbed. (`ObjectSpace.dump_all` and `memsize_of_all` still cover every object in the process; they walk the other objspaces under a global barrier.) * `ObjectSpace.define_finalizer` on another Ractor's object raises `Ractor::IsolationError`; finalizers are registered, stored and run in the owner's objspace. * The `RUBY_INTERNAL_EVENT_FREEOBJ` tracepoint fires only for the main Ractor's objspace (it runs user callbacks from inside the sweep, which is unsafe in a lock-free non-main sweep; extending it is future work). * Invalidated call-cache entries are no longer pruned during GC marking; they are reclaimed at the next lookup of that method or when the class dies. Retention is bounded (one entry per class-and-method-id pair); the worst-case microbenchmark retains approximately 2.7 MB for 200,000 stale entries. ## 4. Performance Setup: 16 logical CPUs (8 cores, SMT), x86_64-linux, `-O3` release builds. `master` is the merge-base of the PR, `RLGC` is the branch. Every cell is wall-clock seconds until all N workers finish (lower is better). Because the machine's clock state drifts on multi-minute scales, each RLGC/master pair was measured interleaved back to back, and each number is the minimum over at least 10 runs spread across several sessions. All three programs are weak-scaling benchmarks: every worker runs the full workload independently, so with perfect parallelism the wall clock stays flat as N grows. The ratio tables divide every cell by master-Ractor N=1, i.e. "how many times the wall time of current Ruby running one Ractor"; a perfectly scaling configuration keeps its N=1 ratio at every N. "Ractor" rows run the N workers as Ractors, "fork" rows run the identical code in forked processes: the no-shared-GC baseline that Ractors should match. Scripts are about 40 lines each and attached to the PR. ### 4.1 binary-trees (D=16; allocation/GC-heavy) The classic benchmarks-game workload: each worker builds and checksums millions of short-lived binary trees (3-element arrays). Allocation and GC dominate; objects die young. seconds: | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 3.83 | 4.01 | 4.48 | 5.76 | 10.26 | | master Ractor | 3.65 | 4.18 | 5.32 | 8.18 | 14.90 | | RLGC fork | 3.75 | 3.99 | 4.48 | 6.56 | 10.22 | | master fork | 3.66 | 3.90 | 4.40 | 6.40 | 10.03 | ratio (x master-Ractor N=1): | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 1.05 | 1.10 | 1.23 | 1.58 | **2.81** | | master Ractor | 1.00 | 1.15 | 1.46 | 2.24 | 4.08 | | RLGC fork | 1.03 | 1.09 | 1.23 | 1.80 | 2.80 | | master fork | 1.00 | 1.07 | 1.21 | 1.75 | 2.75 | RLGC Ractors scale exactly like forked processes (2.81 vs 2.75-2.80 at N=16); master Ractors are 1.45x behind at N=16. For calibration, a zero-allocation pure-integer loop on this machine goes from 5.9s (N=1) to 10.4s (N=16), so RLGC's 10.26s at N=16 is the hardware's SMT ceiling, not a GC limit. ### 4.2 JSON parse (embarrassingly parallel) Each worker parses its own copy of the same ~30 KB JSON document (200 nested user records) 8000 times and discards each result: pure parse-allocate-discard churn, no data crosses workers. This isolates how allocation and GC scale. seconds: | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 1.59 | 1.70 | 1.89 | 2.61 | 4.93 | | master Ractor | 1.49 | 2.06 | 2.63 | 5.24 | 15.10 | | RLGC fork | 1.50 | 1.73 | 1.88 | 2.53 | 4.87 | | master fork | 1.48 | 1.64 | 1.81 | 2.48 | 4.94 | ratio (x master-Ractor N=1): | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 1.07 | 1.14 | 1.27 | 1.76 | **3.32** | | master Ractor | 1.00 | 1.39 | 1.77 | 3.53 | 10.17 | | RLGC fork | 1.01 | 1.17 | 1.27 | 1.71 | 3.28 | | master fork | 0.99 | 1.10 | 1.22 | 1.67 | 3.33 | master Ractors fall off almost immediately (1.39x already at N=2: every Ractor's allocation churn stops all the others) and end 10x over the baseline at N=16; RLGC matches the fork rows within noise the whole way and finishes 3.1x faster than master Ractors. ### 4.3 JSON producer/consumer (Ractor move vs process IPC) The "coordinator hands work to parsers" pattern: the main Ractor/process produces 20000xN JSON strings (~6 KB each) and distributes them round-robin to N workers, which parse and discard them. The Ractor version transfers each string with `send(move: true)`; the fork version writes it down a pipe (a kernel copy). This measures the data-transfer path, not just parallel GC. seconds: | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 0.77 | 0.89 | 1.05 | 1.59 | 2.71 | | master Ractor | 0.71 | 0.99 | 1.68 | 3.87 | 14.59 | | RLGC fork | 0.80 | 0.85 | 0.98 | 1.87 | 3.02 | | master fork | 0.74 | 0.80 | 0.97 | 1.83 | 3.04 | ratio (x master-Ractor N=1): | | N=1 | N=2 | N=4 | N=8 | N=16 | |----------------|------|------|------|------|------| | RLGC Ractor | 1.08 | 1.25 | 1.48 | 2.25 | **3.82** | | master Ractor | 1.00 | 1.40 | 2.37 | 5.45 | 20.55 | | RLGC fork | 1.12 | 1.19 | 1.39 | 2.63 | 4.25 | | master fork | 1.04 | 1.12 | 1.37 | 2.58 | 4.29 | At N=16 RLGC's `move` beats even process IPC (2.71s vs 3.02-3.04s: no kernel copy), while master Ractors are 5.4x behind it. The off-heap courier costs ~8% over master's in-heap move at N=1 (0.77 vs 0.71); that is the price of making in-flight messages invisible to both sides' GCs, and a zero-copy fast path remains possible future work. ### 4.4 Single-Ractor performance Measured with no `Ractor.new` at all (the N=1 rows above already run one worker Ractor, which puts the process in multi-Ractor mode). The allocation fast path itself got faster: the per-Ractor newobj cache is gone and objects come straight off the objspace's bump-pointer heaps. | single Ractor, interleaved minima | RLGC | master | | |---------------------------------------|------|--------|------| | `Object.new` x 20M, GC disabled | 1.83 | 2.01 | **-9%** | | `Object.new` x 20M, GC enabled | 1.18 | 1.10 | +7% | | binary-trees (D=16) | 3.79 | 3.71 | +2% | | JSON parse (8000 docs) | 1.55 | 1.49 | +4% | (`Object.new` rows subtract the loop-only baseline of 0.24s.) Pure allocation wins by 9%; sweep-time bitmap maintenance (the shareable and shref bitmaps) gives ~7% back under allocate-and-discard churn, which nets out to +2-4% on GC-heavy end-to-end workloads and less on anything that does real work between allocations. The multi-objspace machinery is gated behind a single-objspace check on hot paths. ### 4.5 Latency Beyond throughput, the qualitative change is who pauses: on master every GC of any Ractor stops all of them, so one allocation-heavy Ractor imposes its GC pauses on every other Ractor in the process. With per-Ractor GC a Ractor pauses only for its own collections; the remaining process-wide pauses are explicit `GC.start`, shareable-population growth and dead-Ractor cleanup, all of which are rare in steady state. ## 5. Limitations and future work * Incremental marking and non-stop-the-world compaction currently run only while the process has a single objspace. Per-objspace incremental marking is possible in principle (the machinery is per objspace already) but needs the single-to-multi transition handled mid-cycle. * `move:` uses an off-heap serialization; a zero-copy fast path (moving whole pages for large graphs) is future work, as is extending the FREEOBJ tracepoint beyond the main Ractor. * A global GC is still fully stop-the-world; making it concurrent or incremental is orthogonal future work. ## 6. Validation `make btest` (2054 tests) and `make test-all` are green on x86_64-linux across release, `RUBY_DEBUG=1`, ASAN, TSAN and MMTk builds. `GC.verify_internal_consistency` includes the containment verifier (`RGENGC_CHECK_MODE` builds run it around every collection); it was used throughout development. The branch adds ~90 Ractor/GC tests covering lifecycle, copy/move semantics, compaction interaction, and regressions for every bug found during a multi-week stress campaign (a corpus of ~1000 generated Ractor programs run under GC.stress, ASAN and TSAN). ## Notes * This work completes the design presented in [Toward Ractor local GC (RubyKaigi 2025)](https://rubykaigi.org/2025/presentations/ko1.html). * Much of this description, and a large part of the code, was written with Claude Code (Fable/Opus). * Acknowledgements * This work started as a collaboration with Rohit Menon. * Thanks to the Shopify Ractor team for their feedback. * And thank you, everyone, for waiting so long for this work. -- https://bugs.ruby-lang.org/
participants (5)
-
byroot (Jean Boussier) -
Eregon (Benoit Daloze) -
ko1 (Koichi Sasada) -
luke-gru (Luke Gruber) -
matz (Yukihiro Matsumoto)