From fed732c8f4e4e9218bae7a2e5b142dc9b072a40e Mon Sep 17 00:00:00 2001 From: Charles Oliver Nutter Date: Thu, 10 Sep 2026 16:24:03 -0500 Subject: [PATCH 01/13] [ruby/io-wait] Remove extensions and fully deprecate the gem 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 https://github.com/ruby/io-wait/pull/78 https://github.com/ruby/io-wait/commit/4ae06a4278 --- ext/io/wait/depend | 3 --- ext/io/wait/extconf.rb | 4 ---- ext/io/wait/io-wait.gemspec | 16 +++------------- ext/io/wait/wait.c | 23 ----------------------- 4 files changed, 3 insertions(+), 43 deletions(-) delete mode 100644 ext/io/wait/depend delete mode 100644 ext/io/wait/extconf.rb delete mode 100644 ext/io/wait/wait.c diff --git a/ext/io/wait/depend b/ext/io/wait/depend deleted file mode 100644 index 82111bdb888124..00000000000000 --- a/ext/io/wait/depend +++ /dev/null @@ -1,3 +0,0 @@ -# AUTOGENERATED DEPENDENCIES START -wait.o: wait.c -# AUTOGENERATED DEPENDENCIES END diff --git a/ext/io/wait/extconf.rb b/ext/io/wait/extconf.rb deleted file mode 100644 index 00c455a45c8f6f..00000000000000 --- a/ext/io/wait/extconf.rb +++ /dev/null @@ -1,4 +0,0 @@ -# frozen_string_literal: false -require 'mkmf' - -create_makefile("io/wait") diff --git a/ext/io/wait/io-wait.gemspec b/ext/io/wait/io-wait.gemspec index c1c6172589efc5..3ba8a0b00e1b09 100644 --- a/ext/io/wait/io-wait.gemspec +++ b/ext/io/wait/io-wait.gemspec @@ -6,8 +6,8 @@ Gem::Specification.new do |spec| spec.authors = ["Nobu Nakada", "Charles Oliver Nutter"] spec.email = ["nobu@ruby-lang.org", "headius@headius.com"] - spec.summary = %q{Waits until IO is readable or writable without blocking.} - spec.description = %q{Waits until IO is readable or writable without blocking.} + spec.summary = %q{Deprecated: All functionality ships with Ruby 3.2 and higher.} + spec.description = %q{Deprecated: All functionality ships with Ruby 3.2 and higher.} spec.homepage = "https://github.com/ruby/io-wait" spec.licenses = ["Ruby", "BSD-2-Clause"] spec.required_ruby_version = Gem::Requirement.new(">= 3.2") @@ -15,11 +15,9 @@ Gem::Specification.new do |spec| spec.metadata["homepage_uri"] = spec.homepage spec.metadata["source_code_uri"] = spec.homepage - jruby = true if Gem::Platform.new('java') =~ spec.platform or RUBY_ENGINE == 'jruby' dir, gemspec = File.split(__FILE__) excludes = [ - *%w[:^/.git* :^/Gemfile* :^/Rakefile* :^/bin/ :^/test/ :^/rakelib/ :^*.java], - *(jruby ? %w[:^/ext/io] : %w[:^/ext/java]), + *%w[:^/.git* :^/Gemfile* :^/Rakefile* :^/bin/ :^/test/ :^/rakelib/], ":(exclude,literal,top)#{gemspec}" ] files = IO.popen(%w[git ls-files -z --] + excludes, chdir: dir, &:read).split("\x0") @@ -28,12 +26,4 @@ Gem::Specification.new do |spec| spec.bindir = "exe" spec.executables = [] spec.require_paths = ["lib"] - - if jruby - spec.platform = 'java' - spec.files << "lib/io/wait.jar" - spec.require_paths += ["ext/java/lib"] - else - spec.extensions = %w[ext/io/wait/extconf.rb] - end end diff --git a/ext/io/wait/wait.c b/ext/io/wait/wait.c deleted file mode 100644 index f7575191fedf2d..00000000000000 --- a/ext/io/wait/wait.c +++ /dev/null @@ -1,23 +0,0 @@ -/* -*- c-file-style: "ruby"; indent-tabs-mode: t -*- */ -/********************************************************************** - - io/wait.c - - - $Author$ - created at: Tue Aug 28 09:08:06 JST 2001 - - All the files in this distribution are covered under the Ruby's - license (see the file COPYING). - -**********************************************************************/ - -#include "ruby.h" /* abi_version */ - -/* - * IO wait methods are built in ruby now, just for backward compatibility. - */ - -void -Init_wait(void) -{ -} From b0e9197c12085a4522ed3bbe8bf3f0e17e9cdb09 Mon Sep 17 00:00:00 2001 From: Nobuyoshi Nakada Date: Fri, 11 Sep 2026 15:01:53 +0900 Subject: [PATCH 02/13] Remove ext/io/wait c.f. ruby/io-wait#79 --- io.c | 13 +++++++++++++ spec/ruby/core/kernel/require_spec.rb | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/io.c b/io.c index d446991c281577..83ecb651bef351 100644 --- a/io.c +++ b/io.c @@ -16070,4 +16070,17 @@ Init_IO(void) sym_wait_writable = ID2SYM(rb_intern_const("wait_writable")); } +static void init_builtin_io(void); +#define Init_builtin_io init_builtin_io #include "io.rbinc" +#undef Init_builtin_io + +void +Init_builtin_io(void) +{ + init_builtin_io(); + + /* Init_IO is called earlier than `loaded_features` is initialized */ + rb_provide("io/wait.rb"); + rb_provide("io/wait.so"); +} diff --git a/spec/ruby/core/kernel/require_spec.rb b/spec/ruby/core/kernel/require_spec.rb index 1c223c6aa3d8ae..078e97209cec58 100644 --- a/spec/ruby/core/kernel/require_spec.rb +++ b/spec/ruby/core/kernel/require_spec.rb @@ -22,7 +22,7 @@ provided += %w[set pathname] end ruby_version_is "4.1" do - provided += %w[monitor] + provided += %w[monitor io/wait] end out = ruby_exe("puts $LOADED_FEATURES", options: '--disable-gems --disable-did-you-mean') From 6f826af22cd3e77d187fde00c11fcdca458f76a9 Mon Sep 17 00:00:00 2001 From: Nobuyoshi Nakada Date: Sun, 13 Sep 2026 23:44:09 +0900 Subject: [PATCH 03/13] Bump up io-wait not to be overridden --- ext/io/wait/io-wait.gemspec | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ext/io/wait/io-wait.gemspec b/ext/io/wait/io-wait.gemspec index 3ba8a0b00e1b09..147de511ba5153 100644 --- a/ext/io/wait/io-wait.gemspec +++ b/ext/io/wait/io-wait.gemspec @@ -1,4 +1,4 @@ -_VERSION = "0.4.0" +_VERSION = "999.999.999" Gem::Specification.new do |spec| spec.name = "io-wait" @@ -10,7 +10,7 @@ Gem::Specification.new do |spec| spec.description = %q{Deprecated: All functionality ships with Ruby 3.2 and higher.} spec.homepage = "https://github.com/ruby/io-wait" spec.licenses = ["Ruby", "BSD-2-Clause"] - spec.required_ruby_version = Gem::Requirement.new(">= 3.2") + spec.required_ruby_version = Gem::Requirement.new(">= 4.1") spec.metadata["homepage_uri"] = spec.homepage spec.metadata["source_code_uri"] = spec.homepage From 401340437f7f96777b6066f3ff522e3d33eb6007 Mon Sep 17 00:00:00 2001 From: git Date: Sun, 13 Sep 2026 14:57:09 +0000 Subject: [PATCH 04/13] Update default gems list at 6f826af22cd3e77d187fde00c11fcd [ci skip] --- NEWS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/NEWS.md b/NEWS.md index 8f9dd8900d6ef1..cd7ec94bf5ea02 100644 --- a/NEWS.md +++ b/NEWS.md @@ -188,6 +188,7 @@ They are still available on rubygems.org and can be installed with * error_highlight 0.7.2 * io-console 0.9.2 * 0.8.2 to [v0.9.0][io-console-v0.9.0], [v0.9.1][io-console-v0.9.1], [v0.9.2][io-console-v0.9.2] +* io-wait 999.999.999 * ipaddr 1.2.9 * 1.2.8 to [v1.2.9][ipaddr-v1.2.9] * json 3.0.2 From 742b5248764a92dd769ed0250634fac6e3c3b88c Mon Sep 17 00:00:00 2001 From: Scott Myron Date: Sun, 13 Sep 2026 09:05:22 -0500 Subject: [PATCH 05/13] [ruby/erb] Further optimize the NEON SIMD escape path 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. https://github.com/ruby/erb/commit/f8314a7b86 --- ext/erb/escape/escape.c | 126 +++++++++++++++++++++++++++++++++------- 1 file changed, 106 insertions(+), 20 deletions(-) diff --git a/ext/erb/escape/escape.c b/ext/erb/escape/escape.c index 66cb77ed332a7d..3c8aadea9a8107 100644 --- a/ext/erb/escape/escape.c +++ b/ext/erb/escape/escape.c @@ -172,11 +172,93 @@ find_next_match_neon(search_state *search) // uint64_t >>= 64 is undefined behaviour RUBY_ASSERT(trailing_zeros < 64); search->matches_bitmap >>= trailing_zeros; - search->cstr += trailing_zeros / 4; + search->cstr += trailing_zeros; RUBY_ASSERT(search->cstr <= search->end); return true; } +// This 16-byte lookup table is indexed into by using the +// low nibble of each input byte. +// Note: index 0 is intentionally set to a character that will not match +// the NULL byte. +static const uint8x16_t escape_char_by_low_nibble = { + '\'', 0, '"', 0, + 0, 0, '&', '\'', + 0, 0, 0, 0, + '<', 0, '>', 0, +}; + +static inline uint8x16_t +neon_escape_matches(const uint8x16_t bytes) +{ + // An example to demonstrate how this works. The goal is to get a uint8x16_t + // with each lane to equal 0xFF if the corresponding byte in 'bytes' needs + // to be escaped, or 0x00 otherwise. + // + // To keep things very simple, I'm going to assume a vector of length 6, in + // reality, the vector would be 16 bytes wide. + // + // Assume the string is: "
" + // Converted to integers: + // [0x3c 0x62 0x72 0x20 0x2f 0x3e] + // + // Next, we mask off the top nibble so we are left only with the low nibble + // of each byte. We do this by AND'ing each byte with 0x0F. + // + // The result: + // [0x0c 0x02 0x02 0x00 0x0f 0x0e] + // + // Now, we use these low nibbles as indexes into the + // escape_char_by_low_nibble array and find the full byte + // value we expect to match in the input. + // + // The result: + // [0x3c 0x22 0x22 0x27 0x00 0x3e] + // + // Finally, we compare the bytes we expect with the actual input bytes. + // + // The result: + // [0xFF 0x00 0x00 0x00 0x00 0xFF] + const uint8x16_t low_nibbles = vandq_u8(bytes, vdupq_n_u8(0x0F)); + const uint8x16_t looked_up = vqtbl1q_u8(escape_char_by_low_nibble, low_nibbles); + return vceqq_u8(looked_up, bytes); +} + +static inline uint64_t +neon_matches_to_bitmap16(const uint8x16_t matches) +{ + static const uint8x16_t bit_mask = { + 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, + 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, + }; + + uint8x16_t folded = vandq_u8(matches, bit_mask); + folded = vpaddq_u8(folded, folded); + folded = vpaddq_u8(folded, folded); + folded = vpaddq_u8(folded, folded); + + return vgetq_lane_u16(vreinterpretq_u16_u8(folded), 0); +} + +static inline uint64_t +neon_matches_to_bitmap64(const uint8x16_t m0, const uint8x16_t m1, const uint8x16_t m2, const uint8x16_t m3) +{ + static const uint8x16_t bit_mask = { + 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, + 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, + }; + + const uint8x16_t t0 = vandq_u8(m0, bit_mask); + const uint8x16_t t1 = vandq_u8(m1, bit_mask); + const uint8x16_t t2 = vandq_u8(m2, bit_mask); + const uint8x16_t t3 = vandq_u8(m3, bit_mask); + + uint8x16_t folded = vpaddq_u8(vpaddq_u8(t0, t1), vpaddq_u8(t2, t3)); + folded = vpaddq_u8(folded, folded); + + return vgetq_lane_u64(vreinterpretq_u64_u8(folded), 0); +} + static inline bool find_next_neon(search_state *search) { @@ -184,30 +266,34 @@ find_next_neon(search_state *search) return find_next_match_neon(search); } - const uint8x16_t single_quote = vdupq_n_u8('\''); - const uint8x16_t double_quote = vdupq_n_u8('"'); - const uint8x16_t ampersand = vdupq_n_u8('&'); - const uint8x16_t lt = vdupq_n_u8('<'); - const uint8x16_t gt = vdupq_n_u8('>'); + while ((size_t)(search->end - search->cstr) >= sizeof(uint8x16x4_t)) { + const uint8x16_t bytes0 = vld1q_u8(search->cstr + 0); + const uint8x16_t bytes1 = vld1q_u8(search->cstr + 16); + const uint8x16_t bytes2 = vld1q_u8(search->cstr + 32); + const uint8x16_t bytes3 = vld1q_u8(search->cstr + 48); - while ((size_t)(search->end - search->cstr) >= sizeof(uint8x16_t)) { - const uint8x16_t bytes = vld1q_u8(search->cstr); - const uint8x16_t match1 = vceqq_u8(bytes, single_quote); - const uint8x16_t match2 = vceqq_u8(bytes, double_quote); - const uint8x16_t match3 = vceqq_u8(bytes, ampersand); - const uint8x16_t match4 = vceqq_u8(bytes, lt); - const uint8x16_t match5 = vceqq_u8(bytes, gt); + const uint8x16_t m0 = neon_escape_matches(bytes0); + const uint8x16_t m1 = neon_escape_matches(bytes1); + const uint8x16_t m2 = neon_escape_matches(bytes2); + const uint8x16_t m3 = neon_escape_matches(bytes3); - const uint8x16_t mask1 = vorrq_u8(match1, match2); - const uint8x16_t mask2 = vorrq_u8(match3, match4); - const uint8x16_t mask3 = vorrq_u8(mask1, match5); - const uint8x16_t matches = vorrq_u8(mask2, mask3); + const uint64_t bitmap = neon_matches_to_bitmap64(m0, m1, m2, m3); - const uint8x8_t res = vshrn_n_u16(vreinterpretq_u16_u8(matches), 4); - const uint64_t bitmap = vget_lane_u64(vreinterpret_u64_u8(res), 0); + if (bitmap) { + search->matches_bitmap = bitmap; + return find_next_match_neon(search); + } + + search->cstr += 64; + } + + while ((size_t)(search->end - search->cstr) >= sizeof(uint8x16_t)) { + const uint8x16_t bytes = vld1q_u8(search->cstr); + const uint8x16_t matches = neon_escape_matches(bytes); + const uint64_t bitmap = neon_matches_to_bitmap16(matches); if (bitmap) { - search->matches_bitmap = bitmap & 0x8888888888888888ull; + search->matches_bitmap = bitmap; return find_next_match_neon(search); } search->cstr += sizeof(uint8x16_t); From 5a02ff8481fa9b9b09daaf14a53c101636393c67 Mon Sep 17 00:00:00 2001 From: git Date: Sun, 13 Sep 2026 15:01:01 +0000 Subject: [PATCH 06/13] * remove trailing spaces. [ci skip] --- ext/erb/escape/escape.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ext/erb/escape/escape.c b/ext/erb/escape/escape.c index 3c8aadea9a8107..1268aa7b34e020 100644 --- a/ext/erb/escape/escape.c +++ b/ext/erb/escape/escape.c @@ -180,7 +180,7 @@ find_next_match_neon(search_state *search) // This 16-byte lookup table is indexed into by using the // low nibble of each input byte. // Note: index 0 is intentionally set to a character that will not match -// the NULL byte. +// the NULL byte. static const uint8x16_t escape_char_by_low_nibble = { '\'', 0, '"', 0, 0, 0, '&', '\'', @@ -199,7 +199,7 @@ neon_escape_matches(const uint8x16_t bytes) // reality, the vector would be 16 bytes wide. // // Assume the string is: "
" - // Converted to integers: + // Converted to integers: // [0x3c 0x62 0x72 0x20 0x2f 0x3e] // // Next, we mask off the top nibble so we are left only with the low nibble From 308677c7032a5cf76b7a053011534360eda43e64 Mon Sep 17 00:00:00 2001 From: Koichi Sasada Date: Sun, 13 Sep 2026 15:16:47 +0000 Subject: [PATCH 07/13] Ractor#monitor: name the ractor in the exit token 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) --- NEWS.md | 22 +++++++++++++++++++++ bootstraptest/test_ractor.rb | 16 +++++++-------- ractor.rb | 21 ++++++++++++-------- ractor_sync.c | 38 +++++++++++++++++++++++++++++++----- test/ruby/test_ractor.rb | 4 ++-- 5 files changed, 78 insertions(+), 23 deletions(-) diff --git a/NEWS.md b/NEWS.md index cd7ec94bf5ea02..3e1111cecfe318 100644 --- a/NEWS.md +++ b/NEWS.md @@ -388,6 +388,28 @@ A lot of work has gone into making Ractors more stable, performant, and usable. * `ObjectSpace.define_finalizer` on another Ractor's object raises `Ractor::IsolationError`. +* `Ractor#monitor` now sends an Array naming the Ractor and what happened to + it, `[ractor, :exited]` or `[ractor, :aborted]`, where it used to send the + bare Symbol `:exited` or `:aborted`. Several Ractors can then report to one + port and the receiver still knows which one finished. The Array is built for + the receiving Ractor, so watching many Ractors leaves no shareable objects + behind. + + r = Ractor.new { :ok } + r.monitor(port = Ractor::Port.new) + port.receive #=> [r, :exited] + + One port can therefore watch a whole group, which is all a supervisor + needs: + + workers.each { |r| r.monitor port } + + until workers.empty? + r, status = port.receive + workers.delete(r) + workers << restart(r) if status == :aborted + end + ### M:N thread scheduler * The scheduler scales with the number of waiters and of Ractors, where it diff --git a/bootstraptest/test_ractor.rb b/bootstraptest/test_ractor.rb index 4d1b8d924ba8d2..4158556c06bc07 100644 --- a/bootstraptest/test_ractor.rb +++ b/bootstraptest/test_ractor.rb @@ -2710,7 +2710,7 @@ def initialize(a) ## Ractor#monitor -# monitor port returns `:exited` when the monitering Ractor terminated. +# monitor port returns [ractor, :exited] when the monitering Ractor terminated. assert_equal 'true', %q{ r = Ractor.new do Ractor.main << :ok1 @@ -2719,10 +2719,10 @@ def initialize(a) r.monitor port = Ractor::Port.new Ractor.receive # :ok1 - port.receive == :exited + port.receive == [r, :exited] } -# monitor port returns `:exited` even if the monitoring Ractor was terminated. +# monitor port returns [ractor, :exited] even if the monitoring Ractor was terminated. assert_equal 'true', %q{ r = Ractor.new do :ok @@ -2731,7 +2731,7 @@ def initialize(a) r.join # wait for r's terminateion r.monitor port = Ractor::Port.new - port.receive == :exited + port.receive == [r, :exited] } # monitor returns false if the monitoring Ractor was terminated. @@ -2745,7 +2745,7 @@ def initialize(a) r.monitor Ractor::Port.new } -# monitor port returns `:aborted` when the monitering Ractor is aborted. +# monitor port returns [ractor, :aborted] when the monitering Ractor is aborted. assert_equal 'true', %q{ r = Ractor.new do Ractor.main << :ok1 @@ -2754,10 +2754,10 @@ def initialize(a) r.monitor port = Ractor::Port.new Ractor.receive # :ok1 - port.receive == :aborted + port.receive == [r, :aborted] } -# monitor port returns `:aborted` even if the monitoring Ractor was aborted. +# monitor port returns [ractor, :aborted] even if the monitoring Ractor was aborted. assert_equal 'true', %q{ r = Ractor.new do raise 'ok' @@ -2770,7 +2770,7 @@ def initialize(a) end r.monitor port = Ractor::Port.new - port.receive == :aborted + port.receive == [r, :aborted] } assert_equal 'ok', %q{ diff --git a/ractor.rb b/ractor.rb index 48afd71a56fba0..488c47937a5bf4 100644 --- a/ractor.rb +++ b/ractor.rb @@ -599,7 +599,7 @@ def join port = Port.new self.monitor port - if port.receive == :aborted + if port.receive[1] == :aborted __builtin_ractor_value end @@ -632,23 +632,28 @@ def value # call-seq: # ractor.monitor(port) -> true or false # - # Registers the port as a monitoring port for this ractor. When the ractor terminates, - # the port receives a Symbol object. + # Registers the port as a monitoring port for this ractor. When the ractor + # terminates, the port receives an Array naming the ractor and what happened to + # it, so that several ractors can report to one port. # - # * +:exited+ is sent if the ractor terminates without an unhandled exception. - # * +:aborted+ is sent if the ractor terminates by an unhandled exception. + # * [ractor, :exited] if the ractor terminated without an unhandled + # exception. + # * [ractor, :aborted] if it terminated by one. + # + # The Array is built for the receiving ractor, so watching many ractors does not + # leave shareable objects behind. # # Returns +true+ if the monitor was registered (the ractor is still running). # Returns +false+ if the ractor had already terminated; in that case the - # termination message (+:exited+ or +:aborted+) is sent to the port immediately. + # termination message is sent to the port immediately. # # r = Ractor.new{ some_task() } # r.monitor(port = Ractor::Port.new) - # port.receive #=> :exited and r is terminated + # port.receive #=> [r, :exited] # # r = Ractor.new{ raise "foo" } # r.monitor(port = Ractor::Port.new) - # port.receive #=> :aborted and r is terminated by the RuntimeError "foo" + # port.receive #=> [r, :aborted] # def monitor port __builtin_ractor_monitor(port) diff --git a/ractor_sync.c b/ractor_sync.c index fd19b6b3f39cd4..45c319d9c1c1c4 100644 --- a/ractor_sync.c +++ b/ractor_sync.c @@ -16,6 +16,7 @@ static VALUE rb_cRactorPort; static VALUE ractor_receive(rb_execution_context_t *ec, const struct ractor_port *rp, const rb_hrtime_t *end); static VALUE ractor_send(rb_execution_context_t *ec, const struct ractor_port *rp, VALUE obj, VALUE move); static struct ractor_basket *ractor_basket_new_ref(VALUE shareable); +static struct ractor_basket *ractor_basket_new_exit(VALUE sender, VALUE token); static void ractor_send_basket(rb_execution_context_t *ec, const struct ractor_port *rp, struct ractor_basket *b, bool raise_on_error); static void ractor_add_port(rb_ractor_t *r, st_data_t id); @@ -235,6 +236,10 @@ enum ractor_basket_type { basket_type_ref, basket_type_copy, basket_type_move, + /* A ractor's exit token. The pair it becomes is built on the receiving side, + * so a terminating ractor adds no shareable object to the vm, and building it + * needs neither a tag nor a courier -- which the sender has no stack for. */ + basket_type_exit, }; struct ractor_basket { @@ -291,6 +296,9 @@ ractor_basket_mark(const struct ractor_basket *b) /* Marshaled bytes are off-heap and hold nothing to mark. */ rb_gc_mark(b->p.v); } + + /* An exit token names a ractor that may be reachable from nothing else. */ + rb_gc_mark(b->sender); } static void @@ -770,10 +778,12 @@ ractor_mark_monitors(rb_ractor_t *r) } } +/* Paired with the ractor it is about when it is received, so that many ractors + * can report to one port. */ static VALUE -ractor_exit_token(bool exc) +ractor_exit_token(const rb_ractor_t *r) { - if (exc) { + if (r->sync.legacy_exc) { RUBY_DEBUG_LOG("aborted"); return ID2SYM(idAborted); } @@ -807,7 +817,7 @@ ractor_monitor(rb_execution_context_t *ec, VALUE self, VALUE port) if (terminated) { SIZED_FREE(rm); - ractor_port_send(ec, port, ractor_exit_token(r->sync.legacy_exc), Qfalse); + ractor_send_basket(ec, rp, ractor_basket_new_exit(self, ractor_exit_token(r)), false); return Qfalse; } @@ -867,14 +877,14 @@ ractor_notify_exit(rb_execution_context_t *ec, rb_ractor_t *cr, VALUE legacy, bo static void ractor_send_exit_tokens(rb_execution_context_t *ec, rb_ractor_t *cr) { - VALUE token = ractor_exit_token(cr->sync.legacy_exc); + VALUE token = ractor_exit_token(cr); struct ractor_monitor *rm, *nxt; ccan_list_for_each_safe(&cr->sync.monitors, rm, nxt, node) { RUBY_DEBUG_LOG("port:%u@r%u", (unsigned int)ractor_port_id(&rm->port), (unsigned int)rb_ractor_id(rm->port.r)); - ractor_send_basket(ec, &rm->port, ractor_basket_new_ref(token), false); + ractor_send_basket(ec, &rm->port, ractor_basket_new_exit(cr->pub.self, token), false); ccan_list_del(&rm->node); SIZED_FREE(rm); @@ -1208,6 +1218,9 @@ ractor_basket_value(struct ractor_basket *b) switch (b->type) { case basket_type_ref: break; + case basket_type_exit: + /* Allocated here, in the receiving ractor: a copy, not a shared object. */ + return rb_ary_new_from_args(2, b->sender, b->p.v); case basket_type_copy: { /* An off-heap copy courier rebuilds exactly like a move one; only the sources * differ (still alive here, already shells there). */ @@ -1347,6 +1360,7 @@ basket_type_name(enum ractor_basket_type type) case basket_type_ref: return "ref"; case basket_type_copy: return "copy"; case basket_type_move: return "move"; + case basket_type_exit: return "exit"; } VM_ASSERT(0); return NULL; @@ -1676,6 +1690,20 @@ ractor_basket_new_ref(VALUE shareable) return b; } +/* sender is the ractor the token is about; both it and the token are shareable, + * so nothing is copied until the receiver builds the pair. */ +static struct ractor_basket * +ractor_basket_new_exit(VALUE sender, VALUE token) +{ + struct ractor_basket *b = ractor_basket_alloc(); + + b->type = basket_type_exit; + b->sender = sender; + b->p.v = token; + + return b; +} + static VALUE ractor_send0(rb_execution_context_t *ec, const struct ractor_port *rp, VALUE obj, VALUE move, bool raise_on_error) { diff --git a/test/ruby/test_ractor.rb b/test/ruby/test_ractor.rb index 44273d19183698..97b1d8b4f498c1 100644 --- a/test/ruby/test_ractor.rb +++ b/test/ruby/test_ractor.rb @@ -751,7 +751,7 @@ def test_unmonitor_does_not_remove_other_ractors_monitor b = Ractor.new(target) do |t| t.monitor(p = Ractor::Port.new) Ractor.main << :ready - p.receive + p.receive == [t, :exited] end Ractor.receive # b's monitor is registered @@ -765,7 +765,7 @@ def test_unmonitor_does_not_remove_other_ractors_monitor end assert_equal :ok, a.value - assert_equal :exited, b.value + assert_equal true, b.value RUBY end From e3a070a490d67003745bcaa59f338828b009294c Mon Sep 17 00:00:00 2001 From: Koichi Sasada Date: Sun, 13 Sep 2026 15:21:05 +0000 Subject: [PATCH 08/13] Ractor.select: one port for the call, not one per watched ractor 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) --- ractor.rb | 60 ++++++++++++++++++++++++++++++++----------------------- 1 file changed, 35 insertions(+), 25 deletions(-) diff --git a/ractor.rb b/ractor.rb index 488c47937a5bf4..bea2480798ffeb 100644 --- a/ractor.rb +++ b/ractor.rb @@ -314,38 +314,48 @@ def self.count def self.select(*ports, timeout: nil) raise ArgumentError, 'specify at least one Ractor::Port or Ractor' if ports.empty? - monitors = {} # Ractor::Port => Ractor - - ports = ports.map do |arg| - case arg - when Ractor - port = Ractor::Port.new - monitors[port] = arg - arg.monitor port - port - when Ractor::Port - arg - else - raise ArgumentError, "should be Ractor::Port or Ractor" - end - end + monitored = [] + others = [] + mp = nil begin - result = __builtin_ractor_select_internal(ports, timeout) - return nil if result.nil? # timed out + ports.each do |arg| + case arg + when Ractor + monitored << arg + when Ractor::Port + others << arg + else + raise ArgumentError, "should be Ractor::Port or Ractor" + end + end - result_port, obj = result + # One port for every watched ractor rather than one each: the exit token + # names the ractor, so there is nothing to look the result up in. + unless monitored.empty? + mp = Ractor::Port.new + monitored.each { |r| r.monitor mp } + end - if r = monitors[result_port] - [r, r.value] + if others.empty? + # Nothing but ractors: one port to wait on, so no selector is built. + token = mp.receive(timeout: timeout) + return nil if token.nil? else - [result_port, obj] + result = __builtin_ractor_select_internal(mp ? [mp, *others] : others, timeout) + return nil if result.nil? + + port, obj = result + return [port, obj] unless port.equal?(mp) + token = obj end + + r = token[0] + [r, r.value] ensure - # close all ports for join - monitors.each do |port, r| - r.unmonitor port - port.close + if mp + monitored.each { |r| r.unmonitor mp } + mp.close end end end From ab51d15bc3cc6fbf176ccc9ba282f1ce7df1258b Mon Sep 17 00:00:00 2001 From: Koichi Sasada Date: Sun, 13 Sep 2026 13:21:13 +0000 Subject: [PATCH 09/13] GC: fill the stat symbol tables at boot, not on first use 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) --- gc/default/default.c | 203 ++++++++++++++++++++++--------------------- 1 file changed, 103 insertions(+), 100 deletions(-) diff --git a/gc/default/default.c b/gc/default/default.c index 0eae80af6f403f..b2ce456871653d 100644 --- a/gc/default/default.c +++ b/gc/default/default.c @@ -10184,17 +10184,51 @@ rb_gc_impl_gc_count(void *objspace_ptr) return objspace->profile.count; } -static VALUE -gc_info_decode(rb_objspace_t *objspace, const VALUE hash_or_key, const unsigned int orig_flags) +/* Filled by setup_gc_latest_gc_info_symbols() at boot, not on first use. */ +static VALUE sym_major_by, sym_gc_by, sym_immediate_sweep, sym_have_finalizer, sym_state, sym_need_major_by; +static VALUE sym_nofree, sym_oldgen, sym_shady, sym_force, sym_stress; +#if RGENGC_ESTIMATE_OLDMALLOC +static VALUE sym_oldmalloc; +#endif +static VALUE sym_newobj, sym_malloc, sym_method, sym_capi; +static VALUE sym_none, sym_marking, sym_sweeping; +static VALUE sym_weak_references_count; + +static void +setup_gc_latest_gc_info_symbols(void) { - static VALUE sym_major_by = Qnil, sym_gc_by, sym_immediate_sweep, sym_have_finalizer, sym_state, sym_need_major_by; - static VALUE sym_nofree, sym_oldgen, sym_shady, sym_force, sym_stress; +#define S(s) sym_##s = ID2SYM(rb_intern_const(#s)) + S(major_by); + S(gc_by); + S(immediate_sweep); + S(have_finalizer); + S(state); + S(need_major_by); + + S(stress); + S(nofree); + S(oldgen); + S(shady); + S(force); #if RGENGC_ESTIMATE_OLDMALLOC - static VALUE sym_oldmalloc; + S(oldmalloc); #endif - static VALUE sym_newobj, sym_malloc, sym_method, sym_capi; - static VALUE sym_none, sym_marking, sym_sweeping; - static VALUE sym_weak_references_count; + S(newobj); + S(malloc); + S(method); + S(capi); + + S(none); + S(marking); + S(sweeping); + + S(weak_references_count); +#undef S +} + +static VALUE +gc_info_decode(rb_objspace_t *objspace, const VALUE hash_or_key, const unsigned int orig_flags) +{ VALUE hash = Qnil, key = Qnil; VALUE major_by, need_major_by; unsigned int flags = orig_flags ? orig_flags : objspace->profile.latest_gc_info; @@ -10209,36 +10243,6 @@ gc_info_decode(rb_objspace_t *objspace, const VALUE hash_or_key, const unsigned rb_bug("gc_info_decode: non-hash or symbol given"); } - if (NIL_P(sym_major_by)) { -#define S(s) sym_##s = ID2SYM(rb_intern_const(#s)) - S(major_by); - S(gc_by); - S(immediate_sweep); - S(have_finalizer); - S(state); - S(need_major_by); - - S(stress); - S(nofree); - S(oldgen); - S(shady); - S(force); -#if RGENGC_ESTIMATE_OLDMALLOC - S(oldmalloc); -#endif - S(newobj); - S(malloc); - S(method); - S(capi); - - S(none); - S(marking); - S(sweeping); - - S(weak_references_count); -#undef S - } - #define SET(name, attr) \ if (key == sym_##name) \ return (attr); \ @@ -10362,56 +10366,54 @@ static VALUE gc_stat_symbols[gc_stat_sym_last]; static void setup_gc_stat_symbols(void) { - if (gc_stat_symbols[0] == 0) { #define S(s) gc_stat_symbols[gc_stat_sym_##s] = ID2SYM(rb_intern_const(#s)) - S(count); - S(time); - S(marking_time), - S(sweeping_time), - S(heap_allocated_pages); - S(heap_empty_pages); - S(heap_allocatable_bytes); - S(heap_available_slots); - S(heap_live_slots); - S(heap_free_slots); - S(heap_final_slots); - S(heap_marked_slots); - S(heap_eden_pages); - S(total_allocated_pages); - S(total_freed_pages); - S(total_allocated_objects); - S(total_freed_objects); - S(total_malloc_bytes); - S(total_free_bytes); - S(malloc_increase_bytes); - S(malloc_increase_bytes_limit); - S(minor_gc_count); - S(major_gc_count); - S(compact_count); - S(read_barrier_faults); - S(total_moved_objects); - S(remembered_wb_unprotected_objects); - S(remembered_wb_unprotected_objects_limit); - S(old_objects); - S(old_objects_limit); + S(count); + S(time); + S(marking_time), + S(sweeping_time), + S(heap_allocated_pages); + S(heap_empty_pages); + S(heap_allocatable_bytes); + S(heap_available_slots); + S(heap_live_slots); + S(heap_free_slots); + S(heap_final_slots); + S(heap_marked_slots); + S(heap_eden_pages); + S(total_allocated_pages); + S(total_freed_pages); + S(total_allocated_objects); + S(total_freed_objects); + S(total_malloc_bytes); + S(total_free_bytes); + S(malloc_increase_bytes); + S(malloc_increase_bytes_limit); + S(minor_gc_count); + S(major_gc_count); + S(compact_count); + S(read_barrier_faults); + S(total_moved_objects); + S(remembered_wb_unprotected_objects); + S(remembered_wb_unprotected_objects_limit); + S(old_objects); + S(old_objects_limit); #if RGENGC_ESTIMATE_OLDMALLOC - S(oldmalloc_increase_bytes); - S(oldmalloc_increase_bytes_limit); + S(oldmalloc_increase_bytes); + S(oldmalloc_increase_bytes_limit); #endif #if RGENGC_PROFILE - S(total_generated_normal_object_count); - S(total_generated_shady_object_count); - S(total_shade_operation_count); - S(total_promoted_count); - S(total_remembered_normal_object_count); - S(total_remembered_shady_object_count); + S(total_generated_normal_object_count); + S(total_generated_shady_object_count); + S(total_shade_operation_count); + S(total_promoted_count); + S(total_remembered_normal_object_count); + S(total_remembered_shady_object_count); #endif /* RGENGC_PROFILE */ - S(page_pool_arenas); - S(page_pool_arenas_freed); - S(page_pool_total_pages); - S(page_pool_discarded_pages); + S(page_pool_arenas); + S(page_pool_arenas_freed); + S(page_pool_total_pages); + S(page_pool_discarded_pages); #undef S - } } static uint64_t @@ -10428,8 +10430,6 @@ rb_gc_impl_stat(void *objspace_ptr, VALUE hash_or_sym) rb_objspace_t *objspace = objspace_ptr; VALUE hash = Qnil, key = Qnil; - setup_gc_stat_symbols(); - malloc_increase_local_flush(objspace); if (RB_TYPE_P(hash_or_sym, T_HASH)) { @@ -10552,22 +10552,20 @@ static VALUE gc_stat_heap_symbols[gc_stat_heap_sym_last]; static void setup_gc_stat_heap_symbols(void) { - if (gc_stat_heap_symbols[0] == 0) { #define S(s) gc_stat_heap_symbols[gc_stat_heap_sym_##s] = ID2SYM(rb_intern_const(#s)) - S(slot_size); - S(heap_live_slots); - S(heap_free_slots); - S(heap_final_slots); - S(heap_eden_pages); - S(heap_eden_slots); - S(heap_allocatable_slots); - S(total_allocated_pages); - S(force_major_gc_count); - S(force_incremental_marking_finish_count); - S(total_allocated_objects); - S(total_freed_objects); + S(slot_size); + S(heap_live_slots); + S(heap_free_slots); + S(heap_final_slots); + S(heap_eden_pages); + S(heap_eden_slots); + S(heap_allocatable_slots); + S(total_allocated_pages); + S(force_major_gc_count); + S(force_incremental_marking_finish_count); + S(total_allocated_objects); + S(total_freed_objects); #undef S - } } static VALUE @@ -10606,8 +10604,6 @@ rb_gc_impl_stat_heap(void *objspace_ptr, VALUE heap_name, VALUE hash_or_sym) { rb_objspace_t *objspace = objspace_ptr; - setup_gc_stat_heap_symbols(); - if (NIL_P(heap_name)) { if (!RB_TYPE_P(hash_or_sym, T_HASH)) { rb_bug("non-hash given"); @@ -12721,6 +12717,13 @@ rb_gc_impl_objspace_init(void *objspace_ptr) void rb_gc_impl_init(void) { + /* Fill the symbol tables here, where no other ractor exists yet: they used to + * be filled on first use, guarded by their own first element, so a second + * ractor could see a half-filled table and GC.stat raised on the rest. */ + setup_gc_stat_symbols(); + setup_gc_stat_heap_symbols(); + setup_gc_latest_gc_info_symbols(); + VALUE gc_constants = rb_hash_new(); rb_hash_aset(gc_constants, ID2SYM(rb_intern("DEBUG")), GC_DEBUG ? Qtrue : Qfalse); /* Minimum slot size that fits a standard RVALUE */ From abac51a1ceae2c151baec4db328b91e46e85194c Mon Sep 17 00:00:00 2001 From: Koichi Sasada Date: Fri, 4 Sep 2026 22:32:36 +0000 Subject: [PATCH 10/13] gc: give a Ractor's own objspace a smaller initial heap size 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 Co-Authored-By: Claude Opus 5 (1M context) --- gc/default/default.c | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/gc/default/default.c b/gc/default/default.c index b2ce456871653d..339135f2f44718 100644 --- a/gc/default/default.c +++ b/gc/default/default.c @@ -158,6 +158,10 @@ rb_hrtime_sub(rb_hrtime_t a, rb_hrtime_t b) #ifndef GC_HEAP_FREE_SLOTS #define GC_HEAP_FREE_SLOTS 4096 #endif +#ifndef GC_RACTOR_HEAP_INIT_BYTES +/* 0 is resolved at boot to the smallest size that works. */ +#define GC_RACTOR_HEAP_INIT_BYTES 0 +#endif #ifndef GC_HEAP_GROWTH_FACTOR #define GC_HEAP_GROWTH_FACTOR 1.8 #endif @@ -250,6 +254,7 @@ static RB_THREAD_LOCAL_SPECIFIER int malloc_increase_local; typedef struct { size_t heap_init_bytes; + size_t ractor_heap_init_bytes; size_t heap_free_slots; double growth_factor; size_t growth_max_bytes; @@ -271,6 +276,7 @@ typedef struct { static ruby_gc_params_t gc_params = { GC_HEAP_INIT_BYTES, + GC_RACTOR_HEAP_INIT_BYTES, GC_HEAP_FREE_SLOTS, GC_HEAP_GROWTH_FACTOR, GC_HEAP_GROWTH_MAX_BYTES, @@ -933,6 +939,14 @@ static const size_t pool_slot_sizes[HEAP_COUNT] = { #undef SLOT }; +/* An init size below one slot in the largest heap never forces that heap's first + * page, and allocating there then fails with "cannot create a new page after GC". */ +static inline size_t +heap_init_bytes_min(void) +{ + return pool_slot_sizes[HEAP_COUNT - 1]; +} + /* Precomputed reciprocals for fast slot index calculation. * For slot size d: reciprocal = ceil(2^48 / d). * Then offset / d == (uint32_t)((offset * reciprocal) >> 48) @@ -2140,6 +2154,15 @@ heap_page_add_free_region(rb_objspace_t *objspace, struct heap_page *page, VALUE gc_report(3, objspace, "heap_page_add_free_region: %p\n", (void *)obj); } +/* The initial size is per objspace, so a Ractor's own gets a smaller one than + * main's rather than paying main's again. */ +static inline size_t +objspace_heap_init_bytes(const rb_objspace_t *objspace) +{ + return objspace == global_objspace->main_objspace + ? gc_params.heap_init_bytes : gc_params.ractor_heap_init_bytes; +} + static void heap_allocatable_bytes_expand(rb_objspace_t *objspace, rb_heap_t *heap, size_t free_slots, size_t total_slots, size_t slot_size) @@ -2151,7 +2174,7 @@ heap_allocatable_bytes_expand(rb_objspace_t *objspace, target_total_slots = (size_t)(total_slots * gc_params.growth_factor); } else if (total_slots == 0) { - target_total_slots = gc_params.heap_init_bytes / slot_size; + target_total_slots = objspace_heap_init_bytes(objspace) / slot_size; } else { /* Find `f' where free_slots = f * total_slots * goal_ratio @@ -2977,7 +3000,7 @@ heap_prepare(rb_objspace_t *objspace, rb_heap_t *heap) { GC_ASSERT(heap->free_pages == NULL); - if (heap->total_slots < gc_params.heap_init_bytes / heap->slot_size && + if (heap->total_slots < objspace_heap_init_bytes(objspace) / heap->slot_size && heap->sweeping_page == NULL) { heap_page_allocate_and_initialize_force(objspace, heap); GC_ASSERT(heap->free_pages != NULL); @@ -5139,7 +5162,7 @@ gc_sweep_finish_heap(rb_objspace_t *objspace, rb_heap_t *heap) size_t total_slots = heap->total_slots; size_t swept_slots = heap->freed_slots + heap->empty_slots; - size_t init_slots = gc_params.heap_init_bytes / heap->slot_size; + size_t init_slots = objspace_heap_init_bytes(objspace) / heap->slot_size; size_t min_free_slots = (size_t)(MAX(total_slots, init_slots) * gc_params.heap_free_slots_min_ratio); if (swept_slots < min_free_slots && @@ -7182,7 +7205,7 @@ gc_marks_finish(rb_objspace_t *objspace) /* Setup freeable slots. */ size_t total_init_slots = 0; for (int i = 0; i < HEAP_COUNT; i++) { - total_init_slots += (gc_params.heap_init_bytes / heaps[i].slot_size) * r_mul; + total_init_slots += (objspace_heap_init_bytes(objspace) / heaps[i].slot_size) * r_mul; } if (max_free_slots < total_init_slots) { @@ -10847,6 +10870,8 @@ rb_gc_impl_set_params(void *objspace_ptr) get_envparam_size("RUBY_GC_HEAP_FREE_SLOTS", &gc_params.heap_free_slots, 0); get_envparam_size("RUBY_GC_HEAP_INIT_BYTES", &gc_params.heap_init_bytes, 0); + get_envparam_size("RUBY_GC_RACTOR_HEAP_INIT_BYTES", &gc_params.ractor_heap_init_bytes, + heap_init_bytes_min() - 1); get_envparam_double("RUBY_GC_HEAP_GROWTH_FACTOR", &gc_params.growth_factor, 1.0, 0.0, FALSE); get_envparam_size ("RUBY_GC_HEAP_GROWTH_MAX_BYTES", &gc_params.growth_max_bytes, 0); @@ -12693,6 +12718,8 @@ rb_gc_impl_objspace_init(void *objspace_ptr) heap_page_alloc_use_mmap = INIT_HEAP_PAGE_ALLOC_USE_MMAP; #endif gc_params.heap_init_bytes = GC_HEAP_INIT_BYTES; + gc_params.ractor_heap_init_bytes = GC_RACTOR_HEAP_INIT_BYTES ? GC_RACTOR_HEAP_INIT_BYTES + : heap_init_bytes_min(); } // GC.measure_total_time= sets the caller's objspace only; a new Ractor's follows // its creator's, which is the objspace running this init (main starts it on). From a22d46c446bd3a0bc9866d9add2ccadef869388e Mon Sep 17 00:00:00 2001 From: Koichi Sasada Date: Sun, 13 Sep 2026 17:04:50 +0000 Subject: [PATCH 11/13] gc: keep the last page of a heap that has been used 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) --- gc/default/default.c | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/gc/default/default.c b/gc/default/default.c index 339135f2f44718..8941c387821a84 100644 --- a/gc/default/default.c +++ b/gc/default/default.c @@ -5281,8 +5281,9 @@ gc_sweep_step(rb_objspace_t *objspace, rb_heap_t *heap) heap->sweeping_page = ccan_list_next(&heap->pages, sweep_page, page_node); - if (free_slots == sweep_page->total_slots) { - /* There are no living objects, so move this page to the global empty pages. */ + if (free_slots == sweep_page->total_slots && heap->total_pages > 1) { + /* There are no living objects, so move this page to the global empty pages. + * The last one stays: nothing grows a heap that has no pages at all. */ heap_unlink_page(objspace, heap, sweep_page); sweep_page->start = 0; From 51f811afb026afbdead11ccc65d461cc8d6416be Mon Sep 17 00:00:00 2001 From: Koichi Sasada Date: Sun, 13 Sep 2026 14:56:28 +0000 Subject: [PATCH 12/13] gc: count only the Ractor that allocates from an objspace 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) --- gc/default/default.c | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/gc/default/default.c b/gc/default/default.c index 8941c387821a84..e740336f56ab08 100644 --- a/gc/default/default.c +++ b/gc/default/default.c @@ -7188,7 +7188,10 @@ gc_marks_finish(rb_objspace_t *objspace) #endif { - const unsigned long ractor_cnt = rb_gc_vm_ractor_count(); + /* Only this objspace's own Ractor allocates from it. The main objspace + * keeps the VM-wide count it has used since before per-Ractor GC. */ + const unsigned long ractor_cnt = objspace == global_objspace->main_objspace + ? rb_gc_vm_ractor_count() : 1; const unsigned long r_mul = ractor_cnt > 8 ? 8 : ractor_cnt; // upto 8 size_t total_slots = objspace_available_slots(objspace); From 6ca2b168f3790e4972a01686f16f6b82524c699a Mon Sep 17 00:00:00 2001 From: Koichi Sasada Date: Sun, 13 Sep 2026 16:26:36 +0000 Subject: [PATCH 13/13] gc: give RUBY_GC_HEAP_INIT_BYTES a lower bound too 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) --- gc/default/default.c | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/gc/default/default.c b/gc/default/default.c index e740336f56ab08..776eb818cbc7b7 100644 --- a/gc/default/default.c +++ b/gc/default/default.c @@ -10873,7 +10873,8 @@ rb_gc_impl_set_params(void *objspace_ptr) rb_objspace_t *objspace = objspace_ptr; get_envparam_size("RUBY_GC_HEAP_FREE_SLOTS", &gc_params.heap_free_slots, 0); - get_envparam_size("RUBY_GC_HEAP_INIT_BYTES", &gc_params.heap_init_bytes, 0); + get_envparam_size("RUBY_GC_HEAP_INIT_BYTES", &gc_params.heap_init_bytes, + heap_init_bytes_min() - 1); get_envparam_size("RUBY_GC_RACTOR_HEAP_INIT_BYTES", &gc_params.ractor_heap_init_bytes, heap_init_bytes_min() - 1);