[pull] master from ruby:master - #1330
Merged
Merged
Conversation
Bumps the github-actions group with 1 update in the / directory: [taiki-e/install-action](https://github.com/taiki-e/install-action). Updates `taiki-e/install-action` from 2.86.1 to 2.86.2 - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](taiki-e/install-action@288e746...b6b84cf) --- updated-dependencies: - dependency-name: taiki-e/install-action dependency-version: 2.86.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: github-actions ... Signed-off-by: dependabot[bot] <support@github.com>
An unshareable object that the native copier cannot handle is sent as a Marshal dump. That dump was a String in the sender's objspace, kept alive by an in-flight pin until the receiver materialized it. When the message is never received the pin never lifts, so the page holding it stays allocated for the life of the process: GC.start, GC.compact and Port#close all leave it. With per-Ractor GC that is one 64KB heap page per sending Ractor, and it grows without bound. Carry the dump in an xmalloc'd buffer instead, the way a move courier already carries its payload (design_v2.md 4.5). An in-flight payload that is not a GC object needs no pin, so nothing of the sender's heap is held while the message waits, and an unreceived message costs only the buffer. Measured with 3000 send-and-never-receive rounds, each from a Ractor that then exits: payload before after Time 79.9 KB/Ractor 13.7 KB/Ractor (1.00 -> 0.01 page) Set 79.5 KB/Ractor 13.3 KB/Ractor Random 83.6 KB/Ractor 15.7 KB/Ractor 13 KB/Ractor is what a Ractor that sends nothing at all retains, and that part plateaus. Send/receive throughput is unchanged (20k messages, n=9, median us/message: Time 8.75 -> 8.79, Set 4.88 -> 4.84). Natively copied payloads (String, Array, Hash, Object, Struct, MatchData) still build a snapshot in the sender's objspace and still pin it; they keep the old behaviour for now. Marshal does not mark its source (mark_load_arg), and the basket is off the queue while it materializes, so the rebuilt String is rooted from the receiving frame's stack slot. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Step 2 took the marshaled copy payload off-heap; this does the same for the natively copied one, so an unreceived copy no longer pins a page of the sender's heap for any payload type. move_capture gains a copy mode: it reads the sources instead of taking them apart (no husk, no buffer hand-over, no freeing the source's internals), and copy_courier_supported_p decides up front whether the courier can carry the whole graph. MatchData, IO, any other T_DATA and a singleton class are rejected there and keep using the on-heap snapshot path, which handles or rejects them exactly as before. Retention with 3000 send-and-never-receive rounds (KB per sending Ractor, before -> after): String 79.5 -> 14.0 Array 79.5 -> 14.0 Object 79.5 -> 14.0 Hash 79.5 -> 14.0 14 KB is what a Ractor that sends nothing retains, and that part plateaus. A courier is an in-process format, so a shareable payload can be embedded as the VALUE itself rather than costing a whole move_node: child slots now hold either a node id or, with the top bit set, an index into a compact c->refs array that the registry marks. Without that, an array of immediates paid a 72-byte node, a hash lookup and an insert per element, and the message path cost 2.5x what the native copier did (it skipped shareable children). Message throughput (20k messages, n=9, interleaved, median us/message): 100-element Integer array 5.1 -> 4.7 4KB String 16.6 -> 9.3 (one memcpy, not two copies) 20-key Hash 12.0 -> 9.4 nested Hash/Array/String 10.1 -> 7.2 40B String, bare Object unchanged It also restores a behaviour master lost: a String or Array subclass now survives the copy again (4.0.2 copies with #clone and keeps the class; master's native copier builds a base-class object). MyStr < String arrives as MyStr, as it does under Marshal and #clone. move_capture keeps testing the dedup table before shareability: move husks each source as it goes, and a husk is a frozen field-less object that rb_ractor_shareable_p answers true for, so the other order embedded the husk instead of resolving a second occurrence to the first one's node (bootstraptest/test_ractor.rb:661). An immediate skips the lookup, since only captured objects are ever inserted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
With MatchData and an exception's backtrace on the courier, every copy
payload is off-heap: the courier carries the core types and anything else
travels as marshaled bytes. The sender-resident snapshot is gone, and with
it all the machinery that kept one alive across a global GC.
MatchData: rb_match_move_dump takes a release_source flag, since only a move
takes the source's onig region and char_offset apart.
Backtrace: rb_backtrace_blob_dump / _load / _mark copy the frames into an
off-heap blob. A frame only references shareable iseq and method-entry
imemos, so the blob can carry them as they are; it has no compaction update
hook, so the mark pins them (rb_gc_mark, not _movable).
Removed, all of it unreachable once no payload lives in the sender's heap:
rb_ractor_t pin_capture / _cnt / _capa, sending_basket,
gen_fields_capturing, ractor_pin_capture_push
basket p.pinned / p.pinned_cnt, ractor_basket_repin_in_flight,
rb_ractor_repin_in_flight and its queue walks
receiver struct ractor_materialize_frame, ec->materialize_frames and
its mark loop, sync.materializing_copies,
rb_ractor_materializing_p
gc rb_gc_pin_in_flight_message and the whole
rb_gc_impl_pin_in_flight_message hook (default, mmtk, the
modular function table), plus the two verifier relaxations
that existed for a snapshot being materialized
shref bits stay: the write barrier still records them for other reasons.
ractor_copy_native_try and copy_enter stay too, since
Ractor.make_shareable(obj, copy: true) still deep-copies within one objspace.
Message cost, 20k messages per trial, 9 trials, median us per message:
4.0.2 master here
4KB String 3.8 19.4 9.4
20-key Hash 3.3 11.9 7.5
nested Hash/Array/Str 5.3 7.1 5.1
100-elem Integer Array 4.4 5.7 6.9
40B String, Object same across all three
master is well behind 4.0.2 on multi-KB payloads; this recovers about half
of that and puts nested graphs back at 4.0.2's level.
Known issue, already in master and not introduced here: the courier's
registry mark does not traverse a shareable payload, so its children are
collected if a global GC runs while the message is in flight. Unpatched
master crashes on both repros (a dynamic Symbol loses its fstr, a frozen
Array loses its elements) and 4.0.2 does not. It only shows up for move
today; routing copy through the courier makes it reachable from a common
path, so it wants fixing before this ships.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Materializing a string node called rb_enc_str_new, which copies the bytes a second time: once into the courier when the node was built, once out of it when the message arrives. Give the buffer to the String instead. rb_str_new_owned takes an xmalloc'd buffer as a heap String's body, the way rb_str_new_static does for a static one, except the String owns it and frees it like any other. The courier's node hands its pointer over and forgets it, so courier_free no longer frees what the String now owns. Message cost, 20k messages per trial, 9 trials, median us per message: payload 4.0.2 master before here 1KB 2.97 4.11 3.4 3.10 4KB 3.52 8.75 9.4 4.22 8KB 4.26 12.39 12.0 5.08 16KB 5.43 16.24 16.5 6.53 master pulls away from 4.0.2 as the payload grows; this tracks it within about 20% across the range. Small strings are embedded and never had a buffer, so they are unchanged. A heap String is freed by its size (STR_HEAP_SIZE = capa + terminator), so the node carries the capacity of the allocation rather than the bytes in use, and reserves the encoding's terminator length instead of a single NUL. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A basket is rooted by whoever holds it, and nothing else: the port queue it
waits on, or -- while it is on no queue -- its holder's off_queue_baskets
list. rb_ractor_sync grows that list and the basket a second ccan_list_node,
so it can sit on one or the other.
send alloc the basket, put it on the sender's list, then build the
payload into it: rb_ractor_{move,copy}_courier_build publish the
courier into the basket right after ZALLOC, so even a half-built
one is rooted. A move needs this -- it husks the sources as it
captures them, so what the courier has collected loses its other
root as the walk proceeds.
enqueue off the sender's list, onto the receiver's queue
receive off the queue, onto the receiver's list until the basket is freed
The publish is the handover of ownership too: when the capture raises, the
builder re-raises and leaves the courier where it is, and the basket frees it
exactly once as ractor_basket_new unwinds. (The raise path was pointed out by
the Copilot review on #18392.)
Before this, a courier was the one payload ractor_basket_mark skipped: it
left the whole job to vm->ractor.move_courier_registry, which only a global
GC walks. Between the send and the next global GC nothing rooted the
payload, and a shareable object reachable from the courier alone was
collected -- a dynamic Symbol loses its fstr, a frozen Array its elements,
and reading it crashes. A shareable String survived only by being an
fstring, rooted elsewhere. That is already broken in master for move; it
becomes reachable from an ordinary send once copy travels by courier. Two
reproducers, both crashing unpatched master and fine on 4.0.2, now pass:
ports = 5.times.map do |i|
port = Ractor::Port.new
r = Ractor.new(port, i) { |p, n| p.send([:"sym_#{n}"], move: true) }
r.join
port
end
5.times { GC.start }
ports.each { |p| puts p.receive[0].inspect }
(the symbol has to be interpolated: a literal one is held by the iseq) The
second sends Ractor.make_shareable([1, "child"]) the same way.
ractor_sync_mark walks the list next to the queues it already walks, so this
needs no new root pass, and it walks both in every collection. "What a
courier holds is shareable, and only a global GC frees a shareable" does not
hold: pinned_roots_mark, which roots a shareable from its page bit, is
skipped once the process is back to a single Ractor
(rb_gc_single_objspace_p), and then an ordinary local GC frees one that
nothing else names. A payload in flight is named by its basket and nothing
else. The list needs no sync lock even so -- only its owner touches it,
unlike the queues a foreign sender writes. The one caller that does skip it
is the single-objspace root pass, where ractor_mark_unshareable_parts has
just walked the same baskets through ractor_sync_mark.
This retires the registry, its lock, its fork re-initialization and
rb_ractor_move_courier_registry_mark. Walking the registry in every
collection instead of the basket would not do: a receiver's local GC, which
materialize's own allocation can trigger, would then walk a courier another
Ractor is inside move_alloc_ref for -- observed as a crash.
move_alloc_node and move_alloc_ref grow by swapping in a fresh array rather
than realloc, for the same reason the nodes are initialized mark-safe: the
courier is a GC root while it is built, and a realloc can leave c->nodes
pointing at a block it has already freed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two costs on the courier path that the on-heap snapshot did not have. The node and ref arrays were grown by doubling while the graph was captured, and growing them is not a realloc: the courier is a GC root while it is being built, so a realloc would leave the old pointer live across a window where it may already have been freed. Copying into a fresh array instead is correct but not free, and it also made move_alloc_node/move_alloc_ref too big to inline, which costs once per captured object. Both walks that run before capture -- copy_courier_supported_p and move_preflight -- already visit the whole graph, so let them count what capture will allocate: one node per distinct unshareable object, one ref per occurrence of a shareable one. move_courier_reserve then sizes both arrays once and the growth path never runs (verified: it does not fire for any of the benchmark payloads). It stays in place, out of line, in case a count ever comes out short. Materializing an array pushed its elements one at a time, through the capacity check, although the length is known and the shell was allocated with it. Set the length once and write the slots. Message cost, 20k messages per trial, 9 trials, median us per message, measured against master: 40B String 1.40 -> 1.04 4KB String 17.28 -> 8.66 bare Object 1.15 -> 1.05 20-key Hash 12.35 -> 7.61 nested 5.95 -> 5.48 100-elem Integer array 5.70 -> 6.46 The array of immediates is the one shape still behind master. Copy walks the graph twice there (the preflight, then the capture) where the on-heap snapshot walked it once; dropping the preflight would mean capture bailing out on an unsupported type and falling back to Marshal, which also gives up the sizing above. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The off-heap courier started out as the move path's own structure, so every part of it was named move_*. Copy now travels in the same structure and through the same capture walk, with courier_build.copy selecting whether the source is read or taken apart, which left the prefix claiming something untrue: move_capture is what a copy goes through too. Rename the shared machinery to courier_*: the struct and its build/ materialize/mark/free entry points, the node kinds, the capture and materialize walks, and the array allocators. The two builders become rb_ractor_courier_build_copy and rb_ractor_courier_build_move so the pair reads as one family. What is still move-only keeps the move_ prefix -- move_preflight (copy has its own walk, copy_courier_supported_p) and move_neutralize_source, which is exactly the step copy skips. In re.c the MatchData transfer helpers become rb_match_blob_*, matching the neighbouring rb_backtrace_blob_* it sits beside in the same node. No behaviour change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ractor_send_basket freed the basket only on the path that raises
Ractor::ClosedError. With raise_on_error false the closed port leaves the
basket neither enqueued nor freed, and nothing else holds a pointer to it, so
it leaks.
The one caller that passes false is ractor_send_exit_tokens, so a monitor port
that is closed before the Ractor it watches exits leaks one basket per exit.
Counting live baskets over 400 rounds of
mon = Ractor::Port.new
r = Ractor.new { Ractor.receive }
r.monitor(mon)
mon.close
r.send(1)
r.join
gives 401 live before and 1 after; receiving the token instead of closing the
port was already flat, which is what the closed path should match.
Predates this branch -- the same code is in master. It matters more here
because a basket now sits on its holder's off_queue_baskets while it is being
built: a caller that combined ractor_basket_new with raise_on_error false
would leave it on that list, marked in every collection, rather than only
leaking the allocation. No caller does that today.
Found by the Copilot review on #18392.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
courier_alloc_node bumped c->count and then wrote the placeholder fields, so for those few stores the mark walk over nodes[0, count) covered a slot that had not been written yet. courier_alloc_ref already had the other order and says why; make the two match. Not reachable as written: nothing between the bump and the stores allocates or reaches a safepoint, so no collection can start there, and the growth above it happens before the bump. The order is what keeps that true without having to check. Found by the Copilot review on #18392. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rb_gc_finish_in_flight_gc is a one-line wrapper around rb_gc_impl_gc_rest, and every other layer of the same call already says "gc rest": gc_rest in gc/default/default.c, rb_gc_impl_gc_rest across the modular GC boundary. The wrapper exists only because ractor.c cannot call rb_gc_impl_* itself, so it should not rename the operation on the way out. "in flight" also means a message in transit elsewhere in the Ractor code (rb_ractor_repin_in_flight, rb_ractor_mark_in_flight_for_single_objspace), which is a second sense for the word in the subsystem where both GC cycles and messages are the subject. The comment above the definition described the one caller; the caller already carries that, so drop it there and keep the reason where the call is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rb_ractor_mark_in_flight_for_single_objspace marked r->sync.legacy and nothing else once the pin machinery went away, which is what rb_ractor_mark_terminated_join_value next to it already does -- two functions over the same field, and the surviving one names it correctly. Call that one from the single-objspace branch and drop the other. It also pins, which the plain rb_gc_mark did not. sync.legacy is a C-struct slot with no compaction update hook anywhere, so pinning is the treatment the field needs; the comment on rb_ractor_mark_terminated_join_value says so. The name had stopped describing the function: "in flight" meant the sending basket and the pinned copy snapshot it used to mark, and both are gone. The word is also the GC-cycle sense elsewhere in this code, which is why the wrapper it sat next to is now rb_gc_rest. Only the ractor.c translation unit used it -- ractor.c includes ractor_sync.c -- so the ractor_core.h declaration goes too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The root scan marked r->sync.legacy for every zombie objspace, deliberately
"without depending on wrapper reachability". That makes the value a GC root,
and a Ractor whose return value can reach the Ractor object roots itself:
Ractor.new { Ractor.current }.join
Nothing else names either one, but the value is a root, the value reaches the
wrapper, so the wrapper never dies, so ractor_free never runs,
rb_gc_objspace_disown is never called, and the objspace stays in
zombie_objspaces for the life of the process. The count never returning to
zero also holds rb_gc_single_objspace_p() false forever, which is the gate on
page_pool_reclaim, so the page pool stops returning arenas to the OS. A
Ractor::Port, an array, an ivar -- any path from the value back to the wrapper
does it; 10000 rounds of the line above leave 10001 live Ractors.
The value is of use only to whoever can still call Ractor#value, and that
means holding the wrapper, so mark it as the wrapper's child instead. A
self-referential pair is then ordinary garbage, and the transitive case (one
Ractor's value naming another) falls out of normal tracing rather than needing
a pass that iterates to a fixpoint.
Only during a global GC: it stops the world and marks every objspace together,
which is what lets the shareable wrapper reach an unshareable value. A local
GC must not, and never did -- the zombie scan this replaces also ran only in a
global GC.
300 rounds of the line above 10001 -> 1 live Ractors
8000 rounds with a dropped message 2.3 GB -> 216 MB, and it now plateaus
(287 MB at 20000, 288 MB at 40000)
Verified with RUBY_DEBUG=1: bootstraptest/test_ractor.rb, the GC and Ractor
unit tests, GC.verify_internal_consistency after holding 100 terminated
Ractors across GC.compact (their values, including self-references, all still
readable), and the same under GC.stress.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lvar_states only has 3 states, but an enum uses 4 bytes. This commit shrinks
lvar_states to use only 2 bits (0.25 bytes), which means we save 3.75 bytes
for every local variable in an iseq.
We can see that for this script:
def foo
a = 1
b = 1
c = 1
d = 1
end
iseq = RubyVM::InstructionSequence.of(method(:foo))
puts ObjectSpace.memsize_of(iseq)
It outputs 736 before this commit, and 721 now, which is 15 bytes saved
over the 4 local variables.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
See Commits and Changes for more details.
Created by
pull[bot] (v2.0.0-alpha.4)
Can you help keep this open source service alive? 💖 Please sponsor : )