Skip to content

[pull] master from ruby:master - #1406

Merged
pull[bot] merged 13 commits into
turkdevops:masterfrom
ruby:master
Sep 13, 2026
Merged

pull[bot] merged 13 commits into
turkdevops:masterfrom
ruby:master

Conversation

@pull

@pull pull Bot commented Sep 13, 2026

Copy link
Copy Markdown

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 : )

headius and others added 13 commits September 13, 2026 14:30
The functionality of this gem was moved into core Ruby starting
with Ruby 3.2, and at this point both the C extension and the Java
extension define no additional classes or methods. This patch
fully removes both extensions, replacing them with a single
io/wait.rb that warns about the gem deprecation. All other files
have been updated to reflect the removed extensions an deprecation.

Fixes ruby/io-wait#78

ruby/io-wait@4ae06a4278
Using the same technique from Daniel Lemire's blog post:
https://lemire.me/blog/2024/07/20/scan-html-even-faster-with-simd-instructions-c-and-c/)

This has multiple NEON code paths:

1. One for at least 64-bytes remaining in the input.
2. One for at least 16-bytes remaining in the input.

ruby/erb@f8314a7b86
A monitoring port received a bare :exited or :aborted, so a port could only
ever watch one ractor: with two, the token that arrived said what happened
but not to which.  Watching a group meant a port per ractor and a table
from the port back to the ractor -- which is what Ractor.select did, and
what anyone writing a supervisor had to do as well.

The token is now [ractor, :exited] or [ractor, :aborted], so one port can
watch a whole group and a supervisor needs nothing but monitor:

    workers.each { |r| r.monitor port }

    until workers.empty?
      r, status = port.receive
      workers.delete(r)
      workers << restart(r) if status == :aborted
    end

The pair is built by the receiver, from a basket type of its own, rather
than sent as a shareable array.  A shareable array would be simpler -- four
lines against thirty -- but it is left behind for every ractor that ever
terminates, and shareable objects are only reclaimed by a global
collection.  Creating 20,000 ractors and joining them:

                          wall       RSS held afterwards
    built on receipt     309 ms                    98 MB
    shareable array      570 ms                   533 MB

It cannot be sent as an ordinary copy either: the tokens go out from a
thread that has no VM stack left, which is what ractor_basket_new_ref
exists to avoid, and a copy pushes a tag.  Both halves are shareable
already, so the basket holds them as they are and the array is allocated
where it is read.

The array costs join, which received one symbol per call, 0.309us to
0.327us on a terminated ractor.  Ractor.select pays it back many times over
in the commit that follows.

This is an incompatible change to an experimental API, so NEWS says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
select watched each Ractor through a Port it made for that ractor, and kept
a Hash from the port back to the ractor because a bare exit token did not
say which ractor it was about.  Making a port keys it into the ractor's
port table and closing it takes it out again, and keying it into the Hash
assigned it an object_id, which for a T_DATA lives in a side table that
takes its synchronized path as soon as a second ractor exists -- always,
here.  All of that was paid per watched ractor.

Now that a token names its ractor, one port serves the whole call: select
monitors every watched ractor on it and reads the ractor out of the token.
When nothing but ports were passed there is still no port to make, and when
nothing but ractors were passed there is a single port to wait on, so the
selector is not built at all.

Per call, in microseconds of CPU, with none of the watched ractors ever
terminating so that what is measured is building and tearing down the wait
set rather than waiting:

    watched     before      after
          0       0.61       0.61
          1       1.53       1.16
          4       3.65       1.68
         16      12.38       3.66
         64      45.21      11.73
        128      91.10      22.58
        256     187.99      44.49

Per watched ractor, 0.732us to 0.171us.  Three repetitions alternating
which build ran first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
setup_gc_stat_symbols(), setup_gc_stat_heap_symbols() and the symbols
gc_info_decode() decodes were each filled on first use and guarded by
their own first element.  That element is also the first one written, so
a second ractor arriving while the first was still filling saw the guard
already set and skipped the setup, leaving the later entries at 0:

    Warning[:experimental] = false
    port = Ractor::Port.new
    256.times { Ractor.new(port) { |p|
      begin
        GC.stat(:heap_allocated_pages); p << :ok
      rescue ArgumentError => e
        p << e.message
      end
      nil
    } }
    p 256.times.map { port.receive }.reject { _1 == :ok }
    #=> ["unknown key: heap_allocated_pages"]

Reproduces about once per ten runs here; calling GC.stat once on the main
ractor first makes it go away, which is what pointed at the guard.

Fill all three tables from rb_gc_impl_init(), where no other ractor
exists yet, and drop the guards.  gc_info_decode()'s function-local
statics move to file scope so the new setup function can reach them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
heap_init_bytes is a slot count target applied per objspace, bytes divided
by each heap's slot size: heap_prepare fills a heap up to it before the
first collection, gc_sweep_finish_heap puts a floor under the free slots a
sweep should leave, and gc_marks_finish sums it across the heaps as the cap
on how much may stay free, which is what keeps empty pages around.  With
one shared objspace this 2.5 MB was paid once.  With per-Ractor GC every
Ractor's own objspace pays it again, and that is the steady heap a Ractor
holds: a Ractor that allocates 40,000 objects and discards them keeps
4.3 MB, where Ruby 4.0 kept 71 KB.

Add ractor_heap_init_bytes (RUBY_GC_RACTOR_HEAP_INIT_BYTES) and use it for
every objspace but the main one.  Its default, 0, is resolved at boot to
the smallest size that works: one slot in the largest heap.  Below that,
heap_prepare never forces a heap's first page and allocating there fails
with "cannot create a new page after GC", so the environment variable
rejects smaller values the way the other size parameters reject theirs.

Measured with 64 Ractors, each allocating 40,000 objects and then
discarding them, reported after a GC so that it is the heap floor rather
than live data:

    ruby                    kept per Ractor   GCs/Ractor    wall
    4.0.6                          70.9 KB        147.1   271 ms
    master                       4,310.3 KB          1.2   264 ms
    this commit                    302.2 KB         24.5   195 ms

Nothing between the minimum and one page's worth is worth choosing: from
1 KB to 16 KB the GC behaves identically (409 collections, 102 of them
major) and only the memory grows, 302 KB to 857 KB.  The 102 major
collections are all MAJOR_BY_NOFREE, and they stop at 64 KB, which is
HEAP_PAGE_SIZE: below one page's worth of slots, a sweep can never leave
enough free slots behind.  Removing them costs three times the memory
(910 KB) and buys 8% on a Ractor that allocates nothing but garbage, and
nothing at all once a Ractor holds a live set: with 200,000 objects live
the minimum runs 56 collections against 45 and takes 1% longer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A page with nothing live on it leaves its heap for the global empty pages,
and may be returned to the system from there.  When it is the heap's only
page the heap is left with none, and nothing grows a heap in that state:
gc_sweep_finish_heap's growth path asks for total_slots > 0, and
heap_prepare forces a first page only when it is not already sweeping.  An
allocation in that heap then reaches "cannot create a new page after GC".

It takes a heap whose page empties completely while another heap is being
swept, so a Ractor allocating a mix of sizes finds it and one allocating a
single size does not:

    Warning[:experimental] = false
    r = Ractor.new {
      20_000.times { |i| [Array.new(i % 300), "x" * (i % 2000), {a: 1}] }
    }
    r.value

Keep the page instead.  A heap that has been used once holds at least one,
which is what the rest of the code already assumes.  Recovering afterwards
in heap_prepare was measured too and holds the same 224-231 KB per Ractor,
but this keeps the invariant rather than repairing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
gc_marks_finish keeps at least heap_free_slots * r_mul slots free, where
r_mul was the number of Ractors allocating from this objspace, capped at
8.  Per-Ractor GC replaced that count with the VM-wide one, so every
Ractor's objspace now asks for free slots in proportion to how many
Ractors the whole VM has, although only its own Ractor allocates from it.

The floor can then exceed the heap it applies to.  A Ractor started with
the smallest initial heap holds about 7,200 slots; with one other Ractor
in the VM the floor is 4096 * 2, so no sweep can ever leave enough behind
and every collection escalates to a major one:

    1,000,000 allocations on one Ractor, all garbage

                        collections   major   reason
    before                      409     102   nofree
    after                       617       1   oldgen

Lowering RUBY_GC_HEAP_FREE_SLOTS to 1024 has the same effect, which is
what identified the floor.  The major collections free nothing: they cost
8% on a Ractor that allocates nothing but garbage and nothing once one
holds a live set, and the heap does not shrink.  Memory is unchanged
either way, at 298 KB per Ractor.

The main objspace keeps the VM-wide count it has used since before
per-Ractor GC.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The initial heap size is a slot count target, bytes / slot_size for each
heap, so below one slot in the largest heap heap_prepare never forces that
heap's first page.  RUBY_GC_RACTOR_HEAP_INIT_BYTES rejects such values;
RUBY_GC_HEAP_INIT_BYTES accepts anything above zero, and dies partway
through boot:

    $ RUBY_GC_HEAP_INIT_BYTES=512 ./ruby -e 'puts "ok"'
    encdb.so: [BUG] cannot create a new page after major GC

Give it the same bound.  Too small a value is now ignored with a warning
under -w, as one that overflows or does not parse already is:

    RUBY_GC_HEAP_INIT_BYTES=512 (default value: 2621440) is ignored
    because it must be greater than 1023.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@pull pull Bot locked and limited conversation to collaborators Sep 13, 2026
@pull pull Bot added the ⤵️ pull label Sep 13, 2026
@pull
pull Bot merged commit 6ca2b16 into turkdevops:master Sep 13, 2026
1 of 3 checks passed
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants