From 2a96d32a95363109c71e04649a103e0a9218a59c Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Fri, 25 Sep 2026 13:48:21 +0900 Subject: [PATCH 1/9] prism: Use _BitScanForward64 only on 64-bit MSVC _BitScanForward64 is not available for 32-bit x86, so the i386-mswin32 build failed at prism.c. Use the portable fallback there, as ntz_int64 in internal/bits.h does. Co-Authored-By: Claude Opus 5.5 --- prism/internal/bit.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/prism/internal/bit.h b/prism/internal/bit.h index b0111a4c2c0d59..4f7b56090f9a6a 100644 --- a/prism/internal/bit.h +++ b/prism/internal/bit.h @@ -12,7 +12,7 @@ */ #if defined(__GNUC__) || defined(__clang__) #define pm_ctzll(v) ((unsigned) __builtin_ctzll(v)) -#elif defined(_MSC_VER) +#elif defined(_MSC_VER) && defined(_WIN64) #include #include From 413145a2380558a06dc379376b0036e1a4cbd57d Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Fri, 25 Sep 2026 09:01:19 +0900 Subject: [PATCH 2/9] Fix crash in Array#pack when format string modified If the format string is modified during Array#pack (by conversion such as to_str, to_int, etc.), it can cause an use-after-free since reading p[-1] may be an use-after-free. We should read that byte before performing the conversion. The following script crashes: fmt = "a" + " " * 1_000_000 evil = Object.new evil.define_singleton_method(:to_str) { fmt.clear; "A" } [evil].pack(fmt) --- pack.c | 8 ++++++-- test/ruby/test_pack.rb | 12 ++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/pack.c b/pack.c index e4fa33fc43d794..fb750b32fc8803 100644 --- a/pack.c +++ b/pack.c @@ -371,6 +371,7 @@ pack_pack(rb_execution_context_t *ec, VALUE ary, VALUE fmt, VALUE buffer) while (p < pend) { int explicit_endian = 0; size_t align = 0; + bool star = false; if (RSTRING_END(fmt) != pend) { rb_raise(rb_eRuntimeError, "format string modified"); } @@ -392,6 +393,7 @@ pack_pack(rb_execution_context_t *ec, VALUE ary, VALUE fmt, VALUE buffer) p = pack_modifiers(p, pend, type, &natint, &explicit_endian); if (*p == '*') { /* set data length */ + star = true; len = strchr("@Xxu", type) ? 0 : strchr("PMm", type) ? 1 : RARRAY_LEN(ary) - idx; @@ -436,7 +438,9 @@ pack_pack(rb_execution_context_t *ec, VALUE ary, VALUE fmt, VALUE buffer) plen = RSTRING_LEN(from); } - if (p[-1] == '*') + /* The conversion of from with #to_str may have modified fmt, + * so p may be a dangling pointer; use star instead of p[-1]. */ + if (star) len = plen; switch (type) { @@ -445,7 +449,7 @@ pack_pack(rb_execution_context_t *ec, VALUE ary, VALUE fmt, VALUE buffer) case 'Z': /* null terminated string */ if (plen >= len) { rb_str_buf_cat(res, ptr, len); - if (p[-1] == '*' && type == 'Z') + if (star && type == 'Z') rb_str_buf_cat(res, "", 1); } else { diff --git a/test/ruby/test_pack.rb b/test/ruby/test_pack.rb index 91b29d954a2fa9..8d3a4a00a9d220 100644 --- a/test/ruby/test_pack.rb +++ b/test/ruby/test_pack.rb @@ -247,6 +247,18 @@ class << o; self; end.class_eval do end end + def test_format_string_modified_by_to_str + fmt = "a" + " " * 1_000_000 + o = Object.new + o.define_singleton_method(:to_str) do + fmt.clear + "A" + end + assert_raise_with_message(RuntimeError, /format string modified/) do + [o].pack(fmt) + end + end + def test_unpack_with_block_modifying_string # [Bug #22315] s = "C" * 4000 From 90a78de81cd1bce980438a4509f01eca3ce61c01 Mon Sep 17 00:00:00 2001 From: Nobuyoshi Nakada Date: Fri, 25 Sep 2026 16:58:51 +0900 Subject: [PATCH 3/9] Reserve more machine stack space for ASan builds Unoptimized ASan VM frames can exhaust the C stack before `SystemStackError` is raised with the normal reserve. --- gc.c | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/gc.c b/gc.c index 50468fb7dc6e2d..d73b9b9d33f802 100644 --- a/gc.c +++ b/gc.c @@ -2880,6 +2880,7 @@ stack_check(rb_execution_context_t *ec, int water_mark) SET_STACK_END; size_t length = STACK_LENGTH; + if (STACK_LEVEL_MAX <= (size_t)water_mark) return TRUE; size_t maximum_length = STACK_LEVEL_MAX - water_mark; return length > maximum_length; @@ -2888,7 +2889,12 @@ stack_check(rb_execution_context_t *ec, int water_mark) #define stack_check(ec, water_mark) FALSE #endif -#define STACKFRAME_FOR_CALL_CFUNC 2048 +#ifdef RUBY_ASAN_ENABLED +/* Unoptimized, instrumented VM frames can exceed the usual 16KB reserve. */ +# define STACKFRAME_FOR_CALL_CFUNC (128 * 1024 / sizeof(VALUE)) +#else +# define STACKFRAME_FOR_CALL_CFUNC 2048 +#endif int rb_ec_stack_check(rb_execution_context_t *ec) From 050e89ae03026601c13a53cf18fedfef55821dd9 Mon Sep 17 00:00:00 2001 From: Nobuyoshi Nakada Date: Fri, 25 Sep 2026 16:59:05 +0900 Subject: [PATCH 4/9] Honor the buffer size in Darwin `backtrace` Deep native stacks can overrun the caller's buffer while reporting a crash because the Darwin implementation ignores `size`. --- vm_dump.c | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/vm_dump.c b/vm_dump.c index f60c0ed6aa2904..d570870ebccf36 100644 --- a/vm_dump.c +++ b/vm_dump.c @@ -799,10 +799,12 @@ backtrace(void **trace, int size) unw_word_t ip; int n = 0; + if (size <= 0) return 0; + unw_getcontext(&uc); unw_init_local(&cursor, &uc); # if defined(__x86_64__) - while (unw_step(&cursor) > 0) { + while (n < size && unw_step(&cursor) > 0) { unw_get_reg(&cursor, UNW_REG_IP, &ip); trace[n++] = (void *)ip; { @@ -815,6 +817,7 @@ backtrace(void **trace, int size) } return n; darwin_sigtramp: + if (n == size) return n; /* darwin's bundled libunwind doesn't support signal trampoline */ { ucontext_t *uctx; @@ -875,13 +878,14 @@ backtrace(void **trace, int size) /* if segv is caused by invalid call or signal received in syscall */ /* the frame is invalid; skip */ trace[n++] = (void *)ip; + if (n == size) return n; ip = *(unw_word_t*)uctx->uc_mcontext->MCTX_SS_REG(rsp); } trace[n++] = (void *)ip; unw_set_reg(&cursor, UNW_REG_IP, ip); } - while (unw_step(&cursor) > 0) { + while (n < size && unw_step(&cursor) > 0) { unw_get_reg(&cursor, UNW_REG_IP, &ip); trace[n++] = (void *)ip; } @@ -892,7 +896,7 @@ backtrace(void **trace, int size) * unwind can unwind frames without special code. * https://github.com/apple/darwin-libplatform/blob/215b09856ab5765b7462a91be7076183076600df/src/setjmp/generic/sigtramp.c */ - while (unw_step(&cursor) > 0) { + while (n < size && unw_step(&cursor) > 0) { unw_get_reg(&cursor, UNW_REG_IP, &ip); # if defined(__arm64__) // Strip Arm64's pointer authentication. From cfced4edb2be85af1165a9017cfe35b305c109b0 Mon Sep 17 00:00:00 2001 From: Matt Valentine-House Date: Wed, 23 Sep 2026 14:35:12 +0100 Subject: [PATCH 5/9] Fix overlapping GC CPU timing during global compaction --- NEWS.md | 5 +++++ gc/default/default.c | 34 +++++++++++++++++++++++------ test/ruby/test_gc_compact.rb | 42 ++++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 6 deletions(-) diff --git a/NEWS.md b/NEWS.md index 965e293a7e50b3..3add555bc1bf92 100644 --- a/NEWS.md +++ b/NEWS.md @@ -62,6 +62,11 @@ Note: We're only listing outstanding class updates. * The `fiber_interrupt` hook is now required. Schedulers which do not implement it can no longer be installed with `Fiber.set_scheduler`. +* GC + + * Global compaction no longer counts overlapping CPU intervals in scalar GC + timing statistics. Profiler wall-time intervals are unchanged. + * Hash * `Hash.ruby2_keywords_hash?` and `Hash.ruby2_keywords_hash` are diff --git a/gc/default/default.c b/gc/default/default.c index a2bbdbfe4c2212..125ddfe267e8d2 100644 --- a/gc/default/default.c +++ b/gc/default/default.c @@ -9274,6 +9274,22 @@ gc_marking_exit(rb_objspace_t *objspace) gc_prof_mark_timer_stop(objspace); } +static void +gc_sweeping_cpu_enter(rb_objspace_t *objspace) +{ + if (MEASURE_GC) { + gc_clock_start(&objspace->profile.sweeping_start_time); + } +} + +static void +gc_sweeping_cpu_exit(rb_objspace_t *objspace) +{ + if (MEASURE_GC) { + objspace->profile.sweeping_time_ns += gc_clock_end(&objspace->profile.sweeping_start_time); + } +} + static void gc_sweeping_enter(rb_objspace_t *objspace) { @@ -9284,9 +9300,7 @@ gc_sweeping_enter(rb_objspace_t *objspace) objspace->profile.gc_sweep_excluded_wall_time = 0; } - if (MEASURE_GC) { - gc_clock_start(&objspace->profile.sweeping_start_time); - } + gc_sweeping_cpu_enter(objspace); rb_gc_initialize_vm_context(&objspace->vm_context); } @@ -9296,9 +9310,7 @@ gc_sweeping_exit(rb_objspace_t *objspace) { GC_ASSERT(during_gc != 0); - if (MEASURE_GC) { - objspace->profile.sweeping_time_ns += gc_clock_end(&objspace->profile.sweeping_start_time); - } + gc_sweeping_cpu_exit(objspace); if (gc_prof_enabled(objspace)) { rb_hrtime_t sweep_wall_time = elapsed_hrtime_from(objspace->profile.gc_sweep_phase_wall_start_time); @@ -9662,33 +9674,41 @@ gc_start_global(rb_objspace_t *driver, unsigned int reason, bool compact, bool a if (os == driver && driver_prof) { driver_compact_wall_time = rb_hrtime_add(driver_compact_wall_time, elapsed_hrtime_from(t0)); } + gc_sweeping_cpu_exit(os); } /* pass 2 (update): all forwarding now exists, so update every objspace's * references (cross-objspace ones resolve too); gc_compact_finish also unprotects * pages and clears during_compacting. The move-or-mark decision reads * rb_gc_get_objspace()'s during_reference_updating: set it on every objspace. */ + gc_sweeping_cpu_enter(driver); for (size_t i = 0; i < global_objspace->global_gc.n_objspaces; i++) { global_objspace->global_gc.objspaces[i]->flags.during_reference_updating = TRUE; } rb_gc_before_updating_jit_code(); + gc_sweeping_cpu_exit(driver); for (size_t i = 0; i < global_objspace->global_gc.n_objspaces; i++) { rb_objspace_t *os = global_objspace->global_gc.objspaces[i]; + gc_sweeping_cpu_enter(os); rb_hrtime_t t0 = (os == driver && driver_prof) ? rb_hrtime_now() : 0; gc_compact_finish(os); if (os == driver && driver_prof) { driver_compact_wall_time = rb_hrtime_add(driver_compact_wall_time, elapsed_hrtime_from(t0)); } + gc_sweeping_cpu_exit(os); } /* The VM-global / weak-table side of the reference update runs once (each objspace's * heap side already ran in gc_compact_finish above). */ { + gc_sweeping_cpu_enter(driver); rb_hrtime_t t0 = driver_prof ? rb_hrtime_now() : 0; gc_update_references_global(driver); if (driver_prof) { driver_compact_wall_time = rb_hrtime_add(driver_compact_wall_time, elapsed_hrtime_from(t0)); } + gc_sweeping_cpu_exit(driver); } + gc_sweeping_cpu_enter(driver); rb_gc_after_updating_jit_code(); for (size_t i = 0; i < global_objspace->global_gc.n_objspaces; i++) { global_objspace->global_gc.objspaces[i]->flags.during_reference_updating = FALSE; @@ -9696,6 +9716,7 @@ gc_start_global(rb_objspace_t *driver, unsigned int reason, bool compact, bool a } global_objspace->global_gc.compacting = false; uninstall_handlers(); + gc_sweeping_cpu_exit(driver); /* Record the driver's compaction time and exclude it from the driver's sweep phase. * gc_sweeping_exit(driver) in pass 3 subtracts gc_sweep_excluded_wall_time from the @@ -9714,6 +9735,7 @@ gc_start_global(rb_objspace_t *driver, unsigned int reason, bool compact, bool a * T_MOVED as usual. */ for (size_t i = 0; i < global_objspace->global_gc.n_objspaces; i++) { rb_objspace_t *os = global_objspace->global_gc.objspaces[i]; + gc_sweeping_cpu_enter(os); gc_sweep_rest(os); gc_sweeping_exit(os); } diff --git a/test/ruby/test_gc_compact.rb b/test/ruby/test_gc_compact.rb index 3e5c6f2aaabf30..b6788c75a13ddf 100644 --- a/test/ruby/test_gc_compact.rb +++ b/test/ruby/test_gc_compact.rb @@ -105,6 +105,48 @@ def test_gc_verify_compaction_references_not_implemented end end + def test_global_compaction_cpu_intervals_do_not_overlap + omit 'default GC only' unless GC.config[:implementation] == 'default' + omit 'stress' if GC.stress + omit 'thread CPU clock unavailable' unless Process.const_defined?(:CLOCK_THREAD_CPUTIME_ID) + begin + Process.clock_gettime(Process::CLOCK_THREAD_CPUTIME_ID, :nanosecond) + Process.clock_getres(Process::CLOCK_THREAD_CPUTIME_ID, :nanosecond) + rescue SystemCallError, ArgumentError, NotImplementedError + omit 'thread CPU clock unavailable' + end + + assert_separately([{'RUBY_MN_THREADS' => '0'}], __FILE__, __LINE__, <<~'RUBY', timeout: 60) + Warning[:experimental] = false + GC.disable + GC.measure_total_time = true + ready = Ractor::Port.new + worker = Ractor.new(ready) do |reply| + GC.disable + GC.measure_total_time = true + _objects = Array.new(100_000) { Object.new } + before = GC.total_time + reply << :ready + Ractor.receive + GC.total_time - before + end + ready.receive + + clock = Process::CLOCK_THREAD_CPUTIME_ID + cpu_before = Process.clock_gettime(clock, :nanosecond) + gc_before = GC.total_time + 5.times { GC.compact } + gc_ns = GC.total_time - gc_before + cpu_ns = Process.clock_gettime(clock, :nanosecond) - cpu_before + + worker.send(:finish) + gc_ns += worker.value + allowance_ns = [1_000_000, 64 * Process.clock_getres(clock, :nanosecond)].max + assert_operator gc_ns, :>, 0 + assert_operator gc_ns, :<=, cpu_ns + allowance_ns + RUBY + end + def test_gc_compact_stats list = [] From 60c206b683d5e55d0d8b90556c5d84070b05b3e3 Mon Sep 17 00:00:00 2001 From: Matt Valentine-House Date: Wed, 23 Sep 2026 14:36:22 +0100 Subject: [PATCH 6/9] Add global scope to GC.stat --- NEWS.md | 7 + gc.c | 18 +- gc.rb | 89 +++++-- gc/default/default.c | 235 ++++++++++++++++--- gc/gc_impl.h | 4 +- gc/mmtk/mmtk.c | 6 +- gc/wbcheck/wbcheck.c | 6 +- test/ruby/test_gc.rb | 547 +++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 847 insertions(+), 65 deletions(-) diff --git a/NEWS.md b/NEWS.md index 3add555bc1bf92..e64c4dbc066528 100644 --- a/NEWS.md +++ b/NEWS.md @@ -64,6 +64,13 @@ Note: We're only listing outstanding class updates. * GC + * `GC.stat` accepts `scope: :ractor` (the default), `scope: :local` + (a synonym for `:ractor`), and `scope: :global`. + With the default collector, the global scope reports cumulative collection + counts and measured marking/sweeping CPU time across Ractors, including + history from destroyed object spaces, without requesting a stop-the-world + snapshot. Unscoped `GC.stat`, `GC.count`, and `GC.total_time` retain their + current-Ractor behavior. * Global compaction no longer counts overlapping CPU intervals in scalar GC timing statistics. Profiler wall-time intervals are unchanged. diff --git a/gc.c b/gc.c index d73b9b9d33f802..1f087367c71a82 100644 --- a/gc.c +++ b/gc.c @@ -666,7 +666,7 @@ typedef struct gc_function_map { void (*objspace_free)(void *objspace_ptr); void (*ractor_cache_free)(void *objspace_ptr, void *cache); // GC - void (*start)(void *objspace_ptr, bool full_mark, bool immediate_mark, bool immediate_sweep, bool compact); + void (*start)(void *objspace_ptr, bool full_mark, bool immediate_mark, bool immediate_sweep, bool compact, bool global); bool (*during_gc_p)(void *objspace_ptr); void (*prepare_heap)(void *objspace_ptr); void (*gc_enable)(void *objspace_ptr); @@ -3961,9 +3961,9 @@ rb_global_variable(VALUE *var) } static VALUE -gc_start_internal(rb_execution_context_t *ec, VALUE self, VALUE full_mark, VALUE immediate_mark, VALUE immediate_sweep, VALUE compact) +gc_start_internal(rb_execution_context_t *ec, VALUE self, VALUE full_mark, VALUE immediate_mark, VALUE immediate_sweep, VALUE compact, VALUE global) { - rb_gc_impl_start(rb_gc_get_objspace(), RTEST(full_mark), RTEST(immediate_mark), RTEST(immediate_sweep), RTEST(compact)); + rb_gc_impl_start(rb_gc_get_objspace(), RTEST(full_mark), RTEST(immediate_mark), RTEST(immediate_sweep), RTEST(compact), RTEST(global)); return Qnil; } @@ -4044,9 +4044,9 @@ rb_objspace_each_objects(int (*callback)(void *, void *, size_t, void *), void * } } -/* Enumerate every objspace: live Ractors' plus uninherited zombies. Callers hold the - * VM lock (reading another objspace also needs the barrier). Missing even one leaves - * stale mark bits behind for the global GC. */ +/* Enumerate live, creating, and zombie objspaces under the VM lifetime lock. + * Reading foreign mutable collector state also needs the barrier; independently + * synchronized publications may be read under their own locks without a barrier. */ void rb_gc_vm_each_objspace(void (*func)(void *objspace, void *data), void *data) { @@ -5235,7 +5235,7 @@ rb_gc(void) { unless_objspace(objspace) { return; } - rb_gc_impl_start(objspace, true, true, true, false); + rb_gc_impl_start(objspace, true, true, true, false, true); } int @@ -5275,7 +5275,7 @@ rb_gc_latest_gc_info(VALUE key) } static VALUE -gc_stat(rb_execution_context_t *ec, VALUE self, VALUE arg) // arg is (nil || hash || symbol) +gc_stat(rb_execution_context_t *ec, VALUE self, VALUE arg, VALUE global_scope) { if (NIL_P(arg)) { arg = rb_hash_new(); @@ -5284,7 +5284,7 @@ gc_stat(rb_execution_context_t *ec, VALUE self, VALUE arg) // arg is (nil || has rb_raise(rb_eTypeError, "non-hash or symbol given"); } - VALUE ret = rb_gc_impl_stat(rb_gc_get_objspace(), arg); + VALUE ret = rb_gc_impl_stat(RTEST(global_scope) ? NULL : rb_gc_get_objspace(), arg); if (ret == Qundef) { GC_ASSERT(SYMBOL_P(arg)); diff --git a/gc.rb b/gc.rb index 3b3bd1c90797b2..9ade68488a94bc 100644 --- a/gc.rb +++ b/gc.rb @@ -37,16 +37,28 @@ module GC # interleaved with program execution both before the method returns and afterward; # therefore sweeping may not be completed before the return. # + # - +global+: + # a boolean value specifying, when multiple Ractors are running, whether to collect + # every Ractor's heap together in a single stop-the-world mark/sweep cycle: + # + # - +true+: with more than one Ractor it runs a stop-the-world cycle and reclaims shareable + # and cross-Ractor garbage. This option, although it defaults to +true+, is ignored unless + # +full_mark+, +immediate_mark+ and +immediate_sweep+ are all +true+. With one running Ractor + # it has no effect and the other options apply as usual. This option is also ignored unless + # you're using the default GC. + # - +false+: collect only the calling Ractor's own objects. Shareable objects and cross-ractor + # garbage are not swept. + # # Note that these keyword arguments are implementation- and version-dependent, # are not guaranteed to be future-compatible, # and may be ignored in some implementations. - def self.start full_mark: true, immediate_mark: true, immediate_sweep: true - Primitive.gc_start_internal full_mark, immediate_mark, immediate_sweep, false + def self.start full_mark: true, immediate_mark: true, immediate_sweep: true, global: true + Primitive.gc_start_internal full_mark, immediate_mark, immediate_sweep, false, global end # Alias of GC.start - def garbage_collect full_mark: true, immediate_mark: true, immediate_sweep: true - Primitive.gc_start_internal full_mark, immediate_mark, immediate_sweep, false + def garbage_collect full_mark: true, immediate_mark: true, immediate_sweep: true, global: true + Primitive.gc_start_internal full_mark, immediate_mark, immediate_sweep, false, global end # call-seq: @@ -118,6 +130,8 @@ def self.stress=(flag) # # Returns the total number of times garbage collection has occurred: # + # With the default GC, counts the current Ractor's collections. + # # GC.count # => 385 # GC.start # GC.count # => 386 @@ -127,9 +141,9 @@ def self.count end # call-seq: - # GC.stat -> new_hash - # GC.stat(key) -> value - # GC.stat(hash) -> hash + # GC.stat(scope: :ractor) -> new_hash + # GC.stat(key, scope: :ractor) -> value + # GC.stat(hash, scope: :ractor) -> hash # # This method is implementation-specific to CRuby. # @@ -137,7 +151,12 @@ def self.count # The particular statistics are implementation-specific # and may change in the future without notice. # - # With no argument given, + # With scope: :ractor (the default) or scope: :local, + # returns statistics for the current Ractor's object space. + # With scope: :global, returns cumulative collection counts and + # measured GC time across all Ractors. + # + # With no positional argument or +nil+ given, # returns a hash containing the \GC statistics: # # GC.stat @@ -158,6 +177,7 @@ def self.count # malloc_increase_bytes_limit: 16777216, # minor_gc_count: 0, # major_gc_count: 1, + # global_gc_count: 0, # compact_count: 0, # read_barrier_faults: 0, # total_moved_objects: 0, @@ -224,6 +244,8 @@ def self.count # The total number of minor garbage collections run since process start. # - +:major_gc_count+: # The total number of major garbage collections run since process start. + # - +:global_gc_count+: + # The total number of global garbage collections run since process start. # - +:compact_count+: # The total number of compactions run since process start. # - +:read_barrier_faults+: @@ -257,8 +279,38 @@ def self.count # - +:heap_marked_slots+: # The total number of objects marked in the last \GC. # - def self.stat hash_or_key = nil - Primitive.gc_stat hash_or_key + # === Global scope + # + # With scope: :global, the default collector reports cumulative + # collection counts and measured CPU time across all Ractors, retaining + # history from destroyed object spaces: + # + # GC.stat(scope: :global) + # # => {count: 42, minor_gc_count: 32, major_gc_count: 10, + # # time: 3, marking_time: 2, sweeping_time: 1} + # + # These totals include both local and global collections, not only global GC cycles. + # + # +:time+ may exceed +:marking_time+ plus +:sweeping_time+ by one millisecond + # because raw nanoseconds are summed before conversion. Times measure collector + # CPU work, not wall-clock pauses. + # + # Reads combine object-space publications without requesting a stop-the-world + # barrier. GC.measure_total_time controls future timing accumulation. Profiler + # controls do not affect these totals. A forked child inherits its parent's + # totals. MMTk and wbcheck raise NotImplementedError for this scope. + # + # Unknown scopes and unavailable keys raise ArgumentError. + # + def self.stat hash_or_key = nil, scope: :ractor + case scope + when :ractor, :local + Primitive.gc_stat hash_or_key, false + when :global + Primitive.gc_stat hash_or_key, true + else + raise ArgumentError, "unknown scope: #{scope.inspect}" + end end # call-seq: @@ -552,6 +604,11 @@ def self.latest_gc_info hash_or_key = nil # GC.measure_total_time # => true # # Note that when enabled, total time measurement affects performance. + # + # The setting is per-Ractor: it affects only the Ractor that sets it. + # With the default GC, a Ractor created by Ractor.new inherits the + # setting of the Ractor that creates it; the main Ractor starts with + # measurement enabled. def self.measure_total_time=(flag) Primitive.cstmt! %{ rb_gc_impl_set_measure_total_time(rb_gc_get_objspace(), flag); @@ -562,8 +619,9 @@ def self.measure_total_time=(flag) # call-seq: # GC.measure_total_time -> true or false # - # Returns the setting for \GC total time measurement; - # the initial setting is +true+. + # Returns the setting for \GC total time measurement in the current + # Ractor; the main Ractor's initial setting is +true+ and, with the + # default GC, other Ractors inherit their creator's setting. # See GC.total_time. def self.measure_total_time Primitive.cexpr! %{ @@ -576,6 +634,9 @@ def self.measure_total_time # # Returns the \GC total time in nanoseconds: # + # With the default collector, this is the current Ractor's measured GC CPU + # work, not wall-clock pause time. + # # GC.total_time # => 156250 # # Note that total time accumulates @@ -610,8 +671,8 @@ def self.total_time module ObjectSpace # Alias of GC.start - def garbage_collect full_mark: true, immediate_mark: true, immediate_sweep: true - Primitive.gc_start_internal full_mark, immediate_mark, immediate_sweep, false + def garbage_collect full_mark: true, immediate_mark: true, immediate_sweep: true, global: true + Primitive.gc_start_internal full_mark, immediate_mark, immediate_sweep, false, global end module_function :garbage_collect diff --git a/gc/default/default.c b/gc/default/default.c index 125ddfe267e8d2..0bc27677a7959a 100644 --- a/gc/default/default.c +++ b/gc/default/default.c @@ -411,6 +411,7 @@ typedef enum { GPR_FLAG_IMMEDIATE_MARK = 0x8000, GPR_FLAG_FULL_MARK = 0x10000, GPR_FLAG_COMPACT = 0x20000, + GPR_FLAG_GLOBAL = 0x40000, GPR_DEFAULT_REASON = (GPR_FLAG_FULL_MARK | GPR_FLAG_IMMEDIATE_MARK | @@ -605,6 +606,22 @@ struct tdata_unsafe_free_chunk { STATIC_ASSERT(tdata_unsafe_free_bits_cover_chunk, TDATA_UNSAFE_FREE_CHUNK_CAPA <= 32); +struct gc_process_stat_snapshot { + uint32_t count; + uint32_t minor_gc_count; + uint32_t major_gc_count; + uint64_t marking_time_ns; + uint64_t sweeping_time_ns; +}; + +struct gc_process_stat_total { + uint64_t count; + uint64_t minor_gc_count; + uint64_t major_gc_count; + uint64_t marking_time_ns; + uint64_t sweeping_time_ns; +}; + typedef struct rb_objspace { struct { struct gc_malloc_bytes counters; @@ -804,6 +821,14 @@ typedef struct rb_objspace { int fork_vm_lock_lev; struct rb_gc_vm_context vm_context; + + /* Process-wide GC statistics publication. Default GC only: other + * implementations reject GC.stat(scope: :global) and never initialize + * this lock. */ + struct { + rb_nativethread_lock_t lock; + struct gc_process_stat_snapshot published; + } process_stat; } rb_objspace_t; /* The one VM-global GC structure; for now it only holds the page pool. Page bodies are @@ -859,6 +884,7 @@ typedef struct rb_global_objspace { bool compacting; struct rb_objspace **objspaces; size_t n_objspaces, objspaces_capa; + size_t count; } global_gc; /* Index of every objspace's heap pages, ordered by body address. Writers (page @@ -881,6 +907,9 @@ typedef struct rb_global_objspace { struct tdata_unsafe_free_chunk *tdata_unsafe_free_published; /* atomic */ struct tdata_unsafe_free_chunk *tdata_unsafe_free_cache; /* atomic */ size_t tdata_unsafe_free_cache_len; /* atomic */ + + /* Archive of destroyed objspaces' final statistics, added once on absorption. */ + struct gc_process_stat_total process_stat_archive; } rb_global_objspace_t; static rb_global_objspace_t rb_global_objspace_instance; @@ -1552,7 +1581,6 @@ static void init_mark_stack(mark_stack_t *stack); static int garbage_collect(rb_objspace_t *, unsigned int reason); static int gc_start(rb_objspace_t *objspace, unsigned int reason); -static int gc_start_body(rb_objspace_t *objspace, unsigned int reason, bool allow_global); static void gc_rest(rb_objspace_t *objspace); /* GC cycle events (ENTER, EXIT, START, END_MARK, END_SWEEP) fire only if the objspace's @@ -2132,6 +2160,39 @@ rb_gc_impl_get_measure_total_time(void *objspace_ptr) return objspace->flags.measure_gc; } +static void +gc_process_stat_capture(const rb_objspace_t *objspace, + struct gc_process_stat_snapshot *out) +{ + out->count = (uint32_t)objspace->profile.count; + out->minor_gc_count = (uint32_t)objspace->profile.minor_gc_count; + out->major_gc_count = (uint32_t)objspace->profile.major_gc_count; + out->marking_time_ns = objspace->profile.marking_time_ns; + out->sweeping_time_ns = objspace->profile.sweeping_time_ns; +} + +static void +gc_process_stat_publish(rb_objspace_t *objspace) +{ + struct gc_process_stat_snapshot snap; + gc_process_stat_capture(objspace, &snap); + GC_ASSERT(snap.count == snap.minor_gc_count + snap.major_gc_count); + rb_native_mutex_lock(&objspace->process_stat.lock); + objspace->process_stat.published = snap; + rb_native_mutex_unlock(&objspace->process_stat.lock); +} + +static void +gc_process_stat_add(struct gc_process_stat_total *dst, + const struct gc_process_stat_snapshot *src) +{ + dst->count += src->count; + dst->minor_gc_count += src->minor_gc_count; + dst->major_gc_count += src->major_gc_count; + dst->marking_time_ns += src->marking_time_ns; + dst->sweeping_time_ns += src->sweeping_time_ns; +} + /* garbage objects will be collected soon. */ bool rb_gc_impl_garbage_object_p(void *objspace_ptr, VALUE ptr) @@ -8577,15 +8638,13 @@ rb_gc_impl_objspace_retire_gc(void *objspace_ptr) objspace->flags.during_postmortem = 1; gc_rest(objspace); - gc_start_body(objspace, GPR_FLAG_FULL_MARK | GPR_FLAG_IMMEDIATE_MARK | GPR_FLAG_IMMEDIATE_SWEEP, - false); + gc_start(objspace, GPR_FLAG_FULL_MARK | GPR_FLAG_IMMEDIATE_MARK | GPR_FLAG_IMMEDIATE_SWEEP); /* The sweep above turned this heap's dead IO and the like into deferred zombies * (the per-Ractor stdio holds a page per Ractor otherwise); finalize the C-only * ones here and re-sweep the nearly-empty heap so their pages detach as empty. */ if (finalize_deferred_dfree_only(objspace)) { - gc_start_body(objspace, GPR_FLAG_FULL_MARK | GPR_FLAG_IMMEDIATE_MARK | GPR_FLAG_IMMEDIATE_SWEEP, - false); + gc_start(objspace, GPR_FLAG_FULL_MARK | GPR_FLAG_IMMEDIATE_MARK | GPR_FLAG_IMMEDIATE_SWEEP); } heap_pages_freeable_pages = objspace->empty_pages_count; @@ -8727,6 +8786,8 @@ static bool gc_need_global_p(rb_objspace_t *objspace) { if (rb_gc_single_objspace_p()) return false; + /* A Ractor's death must not stop the world, so the retire GC stays local. */ + if (objspace->flags.during_postmortem) return false; if (objspace->shareable_objects > objspace->shareable_objects_limit) return true; /* A zombie's garbage only a global cycle reclaims, but what survived the last one * is live data, so retrigger only once TRIGGER more pages accumulate on top of it. @@ -8760,22 +8821,21 @@ garbage_collect(rb_objspace_t *objspace, unsigned int reason) } static int -gc_start_body(rb_objspace_t *objspace, unsigned int reason, bool allow_global) +gc_start(rb_objspace_t *objspace, unsigned int reason) { unsigned int do_full_mark = !!(reason & GPR_FLAG_FULL_MARK); if (!rb_darray_size(objspace->heap_pages.sorted)) return TRUE; /* heap is not ready */ if (!(reason & GPR_FLAG_METHOD) && !ready_to_gc(objspace)) return TRUE; /* GC is not allowed */ - /* Every local GC entry asks whether a global cycle is needed instead, including the - * allocation slow path, or an allocation-driven workload slips past every threshold - * (only a global cycle reclaims dead shareable objects and zombie pages). The - * exception is the retire GC, which never promotes: a Ractor's death must not STW. */ - if (allow_global && gc_need_global_p(objspace)) { - if (gc_start_global(objspace, reason, false, true)) { + /* An explicit GC.start(global: true) never gets here: rb_gc_impl_start has already decided from + * the `global` keyword, and GPR_FLAG_METHOD keeps `global: false` from being promoted back. */ + if (!(reason & GPR_FLAG_METHOD) && gc_need_global_p(objspace)) { + /* A global GC is always a major, so autocompact applies. */ + if (gc_start_global(objspace, reason, ruby_enable_autocompact, true)) { return TRUE; } - // Fall through to a local GC + /* Fall through to a local GC */ } rb_gc_initialize_vm_context(&objspace->vm_context); @@ -8824,7 +8884,7 @@ gc_start_body(rb_objspace_t *objspace, unsigned int reason, bool allow_global) /* Compaction on the local GC path (autocompact) runs only with a single objspace: * without the stop-the-world barrier, moving objects would break cross-objspace * references. With multiple objspaces GC.compact and autocompact go through the - * compacting global GC instead (rb_gc_impl_start -> gc_start_global). */ + * compacting global GC instead (rb_gc_impl_start, or the promotion above). */ if (do_full_mark && ruby_enable_autocompact && rb_gc_single_objspace_p()) { objspace->flags.during_compacting = TRUE; #if RGENGC_CHECK_MODE @@ -8833,10 +8893,8 @@ gc_start_body(rb_objspace_t *objspace, unsigned int reason, bool allow_global) } else { objspace->flags.during_compacting = !!(reason & GPR_FLAG_COMPACT); - /* The local path was chosen with a single objspace, but another Ractor can be - * born before this point; local compaction would then move shareable objects and - * leave other Ractors' C-struct slots stale, so give up. */ if (objspace->flags.during_compacting && !rb_gc_single_objspace_p()) { + // compaction is currently global GC only with more than 1 running Ractor objspace->flags.during_compacting = FALSE; } } @@ -8900,12 +8958,6 @@ gc_start_body(rb_objspace_t *objspace, unsigned int reason, bool allow_global) return TRUE; } -static int -gc_start(rb_objspace_t *objspace, unsigned int reason) -{ - return gc_start_body(objspace, reason, true); -} - static void gc_rest(rb_objspace_t *objspace) { @@ -9089,6 +9141,13 @@ gc_clock_end(struct timespec *ts) return 0; } +static void +gc_process_stat_after_fork_i(void *objspace_ptr, void *data) +{ + rb_objspace_t *objspace = objspace_ptr; + rb_native_mutex_initialize(&objspace->process_stat.lock); +} + static inline bool gc_local_gc_holds_vm_lock(void) { @@ -9211,6 +9270,7 @@ gc_exit(rb_objspace_t *objspace, enum gc_enter_event event, unsigned int *lock_l RUBY_DEBUG_LOG("%s (%s)", gc_enter_event_cstr(event), gc_current_status(objspace)); gc_report(1, objspace, "gc_exit: %s [%s]\n", gc_enter_event_cstr(event), gc_current_status(objspace)); during_gc = FALSE; + gc_process_stat_publish(objspace); switch (event) { case gc_enter_event_global: @@ -9517,6 +9577,8 @@ gc_start_global(rb_objspace_t *driver, unsigned int reason, bool compact, bool a return false; } + reason |= GPR_FLAG_GLOBAL; + /* A global GC is a collection of the driver's objspace too, and its profile.count * below says so, so report it like a local one. The driver is the objspace whose * count moves, which is the one a hook reading GC.stat would compare against. For @@ -9589,6 +9651,7 @@ gc_start_global(rb_objspace_t *driver, unsigned int reason, bool compact, bool a } } driver->profile.major_gc_count++; + global_objspace->global_gc.count++; /* Enable compaction in every objspace before the mark: the unified conservative root * scan then pins machine-stack referents (gc_pin only pins while during_compacting) @@ -9599,6 +9662,11 @@ gc_start_global(rb_objspace_t *driver, unsigned int reason, bool compact, bool a for (size_t i = 0; i < global_objspace->global_gc.n_objspaces; i++) { rb_objspace_t *objspace = global_objspace->global_gc.objspaces[i]; objspace->flags.during_compacting = TRUE; +#if RGENGC_CHECK_MODE + if (ruby_enable_autocompact) { + objspace->rcompactor.compare_func = ruby_autocompact_compare_func; + } +#endif /* A global GC skips gc_marks_start, which is what resets pinned_slots for a * compacting local GC, so reset it here. step 5 cleared pinned_bits; the * conservative mark re-pins machine-stack referents. */ @@ -9793,7 +9861,10 @@ gc_start_global(rb_objspace_t *driver, unsigned int reason, bool compact, bool a for (size_t i = 0; i < global_objspace->global_gc.n_objspaces; i++) { rb_objspace_t *objspace = global_objspace->global_gc.objspaces[i]; objspace->flags.during_global_gc = FALSE; - if (objspace != driver) during_gc = FALSE; + if (objspace != driver) { + during_gc = FALSE; + gc_process_stat_publish(objspace); + } } /* The unified mark re-established the reachability of absorbed shareable objects, so a @@ -10069,6 +10140,13 @@ objspace_absorb(rb_objspace_t *dst, rb_objspace_t *src) MALLOC_COUNTERS_UNLOCK(dst); } + { + struct gc_process_stat_snapshot final_snap; + gc_process_stat_capture(src, &final_snap); + gc_process_stat_add(&global_objspace->process_stat_archive, &final_snap); + } + rb_native_mutex_destroy(&src->process_stat.lock); + /* Free the shell (as rb_gc_impl_objspace_free does). */ free(src->profile.records); free_stack_chunks(&src->mark_stack); @@ -10101,7 +10179,7 @@ rb_gc_impl_objspace_absorb(void *dst_ptr, void *src_ptr) } void -rb_gc_impl_start(void *objspace_ptr, bool full_mark, bool immediate_mark, bool immediate_sweep, bool compact) +rb_gc_impl_start(void *objspace_ptr, bool full_mark, bool immediate_mark, bool immediate_sweep, bool compact, bool global) { rb_objspace_t *objspace = objspace_ptr; unsigned int reason = (GPR_FLAG_FULL_MARK | @@ -10112,15 +10190,14 @@ rb_gc_impl_start(void *objspace_ptr, bool full_mark, bool immediate_mark, bool i int full_marking_p = gc_config_full_mark_val; gc_config_full_mark_set(TRUE); - /* With multiple objspaces the global GC's barrier makes relocation and the two-phase - * reference update safe across all of them (gc_start_global with compact=true below); - * single-objspace compaction takes the usual local path (gc_start w/ during_compacting). */ - /* For now, compact implies full mark / sweep, so ignore other flags */ if (compact) { GC_ASSERT(GC_COMPACTION_SUPPORTED); reason |= GPR_FLAG_COMPACT; + if (!rb_gc_single_objspace_p()) { + global = true; + } } else { if (!full_mark) reason &= ~GPR_FLAG_FULL_MARK; @@ -10128,10 +10205,12 @@ rb_gc_impl_start(void *objspace_ptr, bool full_mark, bool immediate_mark, bool i if (!immediate_sweep) reason &= ~GPR_FLAG_IMMEDIATE_SWEEP; } - /* An explicit full GC.start with multiple objspaces runs a global GC, the only - * collector that reclaims shareable and cross-objspace garbage. It stops the world, - * so auto_compact is honoured here too (mirroring full mark x autocompact locally). */ - if (!rb_gc_single_objspace_p() && (reason & GPR_FLAG_FULL_MARK)) { + if ((reason & (GPR_FLAG_FULL_MARK | GPR_FLAG_IMMEDIATE_MARK | GPR_FLAG_IMMEDIATE_SWEEP)) != + (GPR_FLAG_FULL_MARK | GPR_FLAG_IMMEDIATE_MARK | GPR_FLAG_IMMEDIATE_SWEEP)) { + global = false; + } + + if (global && !rb_gc_single_objspace_p()) { /* A mid-cycle driver is settled by gc_start_global itself: it aborts the partial * mark and finishes the lazy sweep, so the dead slots are T_NONE before the * unified conservative root scan. */ @@ -10160,7 +10239,7 @@ rb_gc_impl_prepare_heap(void *objspace_ptr) double orig_max_free_slots = gc_params.heap_free_slots_max_ratio; /* Ensure that all empty pages are moved onto empty_pages. */ gc_params.heap_free_slots_max_ratio = 0.0; - rb_gc_impl_start(objspace, true, true, true, true); + rb_gc_impl_start(objspace, true, true, true, true, true); gc_params.heap_free_slots_max_ratio = orig_max_free_slots; objspace->heap_pages.allocatable_bytes = 0; @@ -10819,6 +10898,7 @@ enum gc_stat_sym { gc_stat_sym_malloc_increase_bytes_limit, gc_stat_sym_minor_gc_count, gc_stat_sym_major_gc_count, + gc_stat_sym_global_gc_count, gc_stat_sym_compact_count, gc_stat_sym_read_barrier_faults, gc_stat_sym_total_moved_objects, @@ -10874,6 +10954,7 @@ setup_gc_stat_symbols(void) S(malloc_increase_bytes_limit); S(minor_gc_count); S(major_gc_count); + S(global_gc_count); S(compact_count); S(read_barrier_faults); S(total_moved_objects); @@ -10908,9 +10989,72 @@ ns_to_ms(uint64_t ns) static void malloc_increase_local_flush(rb_objspace_t *objspace); +static void +gc_process_stat_accumulate_i(void *objspace_ptr, void *data) +{ + rb_objspace_t *objspace = objspace_ptr; + struct gc_process_stat_total *total = (struct gc_process_stat_total *)data; + struct gc_process_stat_snapshot snap; + rb_native_mutex_lock(&objspace->process_stat.lock); + snap = objspace->process_stat.published; + rb_native_mutex_unlock(&objspace->process_stat.lock); + gc_process_stat_add(total, &snap); +} + +static VALUE +gc_process_stat(VALUE hash_or_sym) +{ + VALUE hash = Qnil, key = Qnil; + + if (RB_TYPE_P(hash_or_sym, T_HASH)) { + hash = hash_or_sym; + } + else if (SYMBOL_P(hash_or_sym)) { + key = hash_or_sym; + } + else { + rb_bug("non-hash or symbol given"); + } + + struct gc_process_stat_total total; + unsigned int lev = RB_GC_VM_LOCK(); + total = global_objspace->process_stat_archive; + rb_gc_vm_each_objspace(gc_process_stat_accumulate_i, &total); + RB_GC_VM_UNLOCK(lev); + + /* Convert to Ruby values after all collector locks are released. */ + uint64_t time_ns = total.marking_time_ns + total.sweeping_time_ns; + +#define SET64(name, attr) \ + if (key == gc_stat_symbols[gc_stat_sym_##name]) \ + return ULL2NUM(attr); \ + else if (hash != Qnil) \ + rb_hash_aset(hash, gc_stat_symbols[gc_stat_sym_##name], ULL2NUM(attr)); + + SET64(count, total.count); + SET64(time, ns_to_ms(time_ns)); + SET64(marking_time, ns_to_ms(total.marking_time_ns)); + SET64(sweeping_time, ns_to_ms(total.sweeping_time_ns)); + SET64(minor_gc_count, total.minor_gc_count); + SET64(major_gc_count, total.major_gc_count); + +#undef SET64 + + if (!NIL_P(key)) { + /* Matched key should return above. */ + return Qundef; + } + + return hash; +} + VALUE rb_gc_impl_stat(void *objspace_ptr, VALUE hash_or_sym) { + if (objspace_ptr == NULL) { + return gc_process_stat(hash_or_sym); + } + rb_objspace_t *objspace = objspace_ptr; VALUE hash = Qnil, key = Qnil; @@ -10960,6 +11104,7 @@ rb_gc_impl_stat(void *objspace_ptr, VALUE hash_or_sym) SET(malloc_increase_bytes_limit, malloc_limit); SET(minor_gc_count, objspace->profile.minor_gc_count); SET(major_gc_count, objspace->profile.major_gc_count); + SET(global_gc_count, global_objspace->global_gc.count); SET(compact_count, objspace->profile.compact_count); SET(read_barrier_faults, objspace->profile.read_barrier_faults); SET(total_moved_objects, objspace->rcompactor.total_moved); @@ -12803,7 +12948,8 @@ gc_compact_stats(VALUE self) * * This function compacts objects together in Ruby's heap. It eliminates * unused space (or fragmentation) in the heap by moving objects in to that - * unused space. + * unused space. If there is more than 1 running Ractor, it runs a global + * GC compaction (all object spaces). * * The returned +hash+ contains statistics about the objects that were moved; * see GC.latest_compact_info. @@ -12822,7 +12968,7 @@ gc_compact(VALUE self) gc_config_full_mark_set(TRUE); /* Run GC with compaction enabled */ - rb_gc_impl_start(rb_gc_get_objspace(), true, true, true, true); + rb_gc_impl_start(rb_gc_get_objspace(), true, true, true, true, true); gc_config_full_mark_set(full_marking_p); return gc_compact_stats(self); @@ -12899,12 +13045,12 @@ gc_verify_compaction_references(int argc, VALUE* argv, VALUE self) * moved-reference walk) is built for a single objspace, so with several demote it * to a plain full GC. Plain GC.compact does compact them via the global GC. */ if (!rb_gc_single_objspace_p()) { - rb_gc_impl_start(objspace, true, true, true, false); + rb_gc_impl_start(objspace, true, true, true, false, false); return gc_compact_stats(self); } /* Clear the heap. */ - rb_gc_impl_start(objspace, true, true, true, false); + rb_gc_impl_start(objspace, true, true, true, false, false); unsigned int lev = RB_GC_VM_LOCK(); { @@ -12964,7 +13110,7 @@ gc_verify_compaction_references(int argc, VALUE* argv, VALUE self) } RB_GC_VM_UNLOCK(lev); - rb_gc_impl_start(rb_gc_get_objspace(), true, true, true, true); + rb_gc_impl_start(rb_gc_get_objspace(), true, true, true, true, false); rb_objspace_reachable_objects_from_root(root_obj_check_moved_i, objspace); objspace_each_objects(objspace, heap_check_moved_i, objspace, TRUE); @@ -13010,6 +13156,8 @@ rb_gc_impl_objspace_free(void *objspace_ptr) rb_native_mutex_destroy(&objspace->malloc_counters.lock); #endif + rb_native_mutex_destroy(&objspace->process_stat.lock); + free(objspace); } @@ -13061,6 +13209,10 @@ rb_gc_impl_after_fork(void *objspace_ptr, rb_pid_t pid) { rb_objspace_t *objspace = objspace_ptr; + if (pid == 0) { + rb_gc_vm_each_objspace(gc_process_stat_after_fork_i, NULL); + } + RB_GC_VM_UNLOCK(objspace->fork_vm_lock_lev); objspace->fork_vm_lock_lev = 0; @@ -13132,6 +13284,9 @@ rb_gc_impl_objspace_alloc(void) global_objspace_init(); rb_objspace_t *objspace = calloc1(sizeof(rb_objspace_t)); + if (objspace) { + rb_native_mutex_initialize(&objspace->process_stat.lock); + } return objspace; } @@ -13203,6 +13358,8 @@ rb_gc_impl_objspace_init(void *objspace_ptr) objspace->profile.invoke_wall_time = rb_hrtime_now(); objspace->profile.max_records = GC_PROFILE_RECORD_DEFAULT_MAX_RECORDS; finalizer_table = st_init_numtable(); + + gc_process_stat_publish(objspace); } void diff --git a/gc/gc_impl.h b/gc/gc_impl.h index 76634e62992664..e7a116f2a68b2a 100644 --- a/gc/gc_impl.h +++ b/gc/gc_impl.h @@ -63,7 +63,7 @@ GC_IMPL_FN void rb_gc_impl_shutdown_free_objects(void *objspace_ptr); GC_IMPL_FN void rb_gc_impl_objspace_free(void *objspace_ptr); GC_IMPL_FN void rb_gc_impl_ractor_cache_free(void *objspace_ptr, void *cache); // GC -GC_IMPL_FN void rb_gc_impl_start(void *objspace_ptr, bool full_mark, bool immediate_mark, bool immediate_sweep, bool compact); +GC_IMPL_FN void rb_gc_impl_start(void *objspace_ptr, bool full_mark, bool immediate_mark, bool immediate_sweep, bool compact, bool global); GC_IMPL_FN bool rb_gc_impl_during_gc_p(void *objspace_ptr); GC_IMPL_FN void rb_gc_impl_prepare_heap(void *objspace_ptr); GC_IMPL_FN void rb_gc_impl_gc_enable(void *objspace_ptr); @@ -162,6 +162,8 @@ GC_IMPL_FN bool rb_gc_impl_get_measure_total_time(void *objspace_ptr); GC_IMPL_FN unsigned long long rb_gc_impl_get_total_time(void *objspace_ptr); GC_IMPL_FN size_t rb_gc_impl_gc_count(void *objspace_ptr); GC_IMPL_FN VALUE rb_gc_impl_latest_gc_info(void *objspace_ptr, VALUE key); +/* A NULL objspace requests cumulative process-wide statistics. Collectors that + * do not support this scope must raise NotImplementedError. */ GC_IMPL_FN VALUE rb_gc_impl_stat(void *objspace_ptr, VALUE hash_or_sym); GC_IMPL_FN VALUE rb_gc_impl_stat_heap(void *objspace_ptr, VALUE heap_name, VALUE hash_or_sym); GC_IMPL_FN const char *rb_gc_impl_active_gc_name(void); diff --git a/gc/mmtk/mmtk.c b/gc/mmtk/mmtk.c index 6245dffd9c10b7..a7d0544a4edabd 100644 --- a/gc/mmtk/mmtk.c +++ b/gc/mmtk/mmtk.c @@ -797,7 +797,7 @@ rb_gc_impl_shutdown_free_objects(void *objspace_ptr) // GC void -rb_gc_impl_start(void *objspace_ptr, bool full_mark, bool immediate_mark, bool immediate_sweep, bool compact) +rb_gc_impl_start(void *objspace_ptr, bool full_mark, bool immediate_mark, bool immediate_sweep, bool compact, bool global) { mmtk_handle_user_collection_request(rb_gc_get_ractor_newobj_cache(), true, full_mark); } @@ -1692,6 +1692,10 @@ setup_gc_stat_symbols(void) VALUE rb_gc_impl_stat(void *objspace_ptr, VALUE hash_or_sym) { + if (objspace_ptr == NULL) { + rb_notimplement(); + } + struct objspace *objspace = objspace_ptr; VALUE hash = Qnil, key = Qnil; diff --git a/gc/wbcheck/wbcheck.c b/gc/wbcheck/wbcheck.c index 9e4d7e58f412b3..98697a386e8723 100644 --- a/gc/wbcheck/wbcheck.c +++ b/gc/wbcheck/wbcheck.c @@ -564,7 +564,7 @@ rb_gc_impl_ractor_cache_free(void *objspace_ptr, void *cache) // GC void -rb_gc_impl_start(void *objspace_ptr, bool full_mark, bool immediate_mark, bool immediate_sweep, bool compact) +rb_gc_impl_start(void *objspace_ptr, bool full_mark, bool immediate_mark, bool immediate_sweep, bool compact, bool global) { rb_wbcheck_objspace_t *objspace = (rb_wbcheck_objspace_t *)objspace_ptr; if (objspace) { @@ -1825,6 +1825,10 @@ rb_gc_impl_latest_gc_info(void *objspace_ptr, VALUE key) VALUE rb_gc_impl_stat(void *objspace_ptr, VALUE hash_or_sym) { + if (objspace_ptr == NULL) { + rb_notimplement(); + } + rb_wbcheck_objspace_t *objspace = (rb_wbcheck_objspace_t *)objspace_ptr; GC_ASSERT(objspace); diff --git a/test/ruby/test_gc.rb b/test/ruby/test_gc.rb index c28d1f496564cd..f46a3852be1db4 100644 --- a/test/ruby/test_gc.rb +++ b/test/ruby/test_gc.rb @@ -1071,4 +1071,551 @@ def test_finalizer_not_run_with_vm_lock end RUBY end + + def test_stat_global_scope_matches_local_statistics + omit 'default GC only' unless GC.config[:implementation] == 'default' + omit 'stress' if GC.stress + + assert_separately([], __FILE__, __LINE__, <<~'RUBY', timeout: 60) + GC.disable + local = GC.stat + process = GC.stat(scope: :global) + keys = %i[count minor_gc_count major_gc_count time marking_time sweeping_time] + assert_equal local.values_at(*keys), process.values_at(*keys) + RUBY + end + + def test_stat_global_scope_count_consistency + omit 'default GC only' unless GC.config[:implementation] == 'default' + + stat = GC.stat(scope: :global) + assert_equal stat[:minor_gc_count] + stat[:major_gc_count], stat[:count] + end + + def test_stat_global_scope_time_rounding + omit 'default GC only' unless GC.config[:implementation] == 'default' + + stat = GC.stat(scope: :global) + assert_include 0..1, stat[:time] - (stat[:marking_time] + stat[:sweeping_time]) + end + + def test_stat_global_scope_no_argument_returns_new_hash + omit 'default GC only' unless GC.config[:implementation] == 'default' + + stat = GC.stat(scope: :global) + keys = %i[count minor_gc_count major_gc_count time marking_time sweeping_time] + assert_kind_of Hash, stat + assert_not_same stat, GC.stat(scope: :global) + assert_equal keys.sort, stat.keys.sort + assert stat.values.all? { |value| Integer === value } + end + + def test_stat_global_scope_nil_argument_returns_new_hash + omit 'default GC only' unless GC.config[:implementation] == 'default' + + stat = GC.stat(nil, scope: :global) + keys = %i[count minor_gc_count major_gc_count time marking_time sweeping_time] + assert_kind_of Hash, stat + assert_not_same stat, GC.stat(scope: :global) + assert_equal keys.sort, stat.keys.sort + assert stat.values.all? { |value| Integer === value } + end + + def test_stat_global_scope_symbol_argument_returns_selected_value + omit 'default GC only' unless GC.config[:implementation] == 'default' + omit 'stress' if GC.stress + + assert_separately([], __FILE__, __LINE__, <<~'RUBY', timeout: 60) + GC.disable + stat = GC.stat(scope: :global) + value = GC.stat(:count, scope: :global) + assert_kind_of Integer, value + assert_equal stat[:count], value + RUBY + end + + def test_stat_global_scope_supplied_hash_is_updated_and_preserved + omit 'default GC only' unless GC.config[:implementation] == 'default' + + keys = %i[count minor_gc_count major_gc_count time marking_time sweeping_time] + buffer = { sentinel: :keep } + assert_same buffer, GC.stat(buffer, scope: :global) + assert_equal :keep, buffer[:sentinel] + assert_equal keys.sort, (buffer.keys - [:sentinel]).sort + assert buffer.values_at(*keys).all? { |value| Integer === value } + end + + def test_stat_global_scope_supplied_hash_overwrites_stale_values + omit 'default GC only' unless GC.config[:implementation] == 'default' + omit 'stress' if GC.stress + + assert_separately([], __FILE__, __LINE__, <<~'RUBY', timeout: 60) + GC.disable + buffer = { count: -1, time: -1, sentinel: :keep } + assert_same buffer, GC.stat(buffer, scope: :global) + assert_equal :keep, buffer[:sentinel] + assert_equal GC.stat(:count, scope: :global), buffer[:count] + assert_equal GC.stat(:time, scope: :global), buffer[:time] + RUBY + end + + def test_stat_global_scope_rejects_non_hash_or_symbol_arguments + omit 'default GC only' unless GC.config[:implementation] == 'default' + + assert_raise(TypeError) { GC.stat(0, scope: :global) } + assert_raise(TypeError) { GC.stat("count", scope: :global) } + end + + def test_stat_global_scope_rejects_unknown_keys + omit 'default GC only' unless GC.config[:implementation] == 'default' + + assert_raise(ArgumentError) { GC.stat(:no_such_key, scope: :global) } + assert_raise(ArgumentError) { GC.stat(:"café", scope: :global) } + end + + def test_stat_global_scope_rejects_extra_positional_arguments + omit 'default GC only' unless GC.config[:implementation] == 'default' + + assert_raise(ArgumentError) { GC.stat(:count, :time, scope: :global) } + end + + def test_stat_global_scope_rejects_frozen_hash + omit 'default GC only' unless GC.config[:implementation] == 'default' + + assert_raise(FrozenError) { GC.stat({ sentinel: 1 }.freeze, scope: :global) } + end + + def test_stat_global_scope_counts_deferred_collection_once + omit 'default GC only' unless GC.config[:implementation] == 'default' + omit 'stress' if GC.stress + + assert_separately([], __FILE__, __LINE__, <<~'RUBY', timeout: 60) + GC.disable + before = GC.stat(scope: :global) + GC.start(full_mark: true, immediate_mark: false, immediate_sweep: false) + started = GC.stat(scope: :global) + assert_equal [1, 1], [started[:count] - before[:count], started[:major_gc_count] - before[:major_gc_count]] + + GC.start(full_mark: false, immediate_mark: true, immediate_sweep: true) + settled = GC.stat(scope: :global) + assert_equal 1, settled[:count] - started[:count] + RUBY + end + + def test_stat_global_scope_counts_collection_with_profiler_disabled + omit 'default GC only' unless GC.config[:implementation] == 'default' + omit 'stress' if GC.stress + + assert_separately([], __FILE__, __LINE__, <<~'RUBY', timeout: 60) + GC.disable + GC::Profiler.disable + before = GC.stat(:count, scope: :global) + GC.start + assert_equal 1, GC.stat(:count, scope: :global) - before + RUBY + end + + def test_stat_global_scope_profiler_enable_preserves_totals + omit 'default GC only' unless GC.config[:implementation] == 'default' + omit 'stress' if GC.stress + + assert_separately([], __FILE__, __LINE__, <<~'RUBY', timeout: 60) + GC.disable + GC::Profiler.disable + GC.start + before = GC.stat(scope: :global) + GC::Profiler.enable + assert_equal before, GC.stat(scope: :global) + RUBY + end + + def test_stat_global_scope_profiler_disable_preserves_totals + omit 'default GC only' unless GC.config[:implementation] == 'default' + omit 'stress' if GC.stress + + assert_separately([], __FILE__, __LINE__, <<~'RUBY', timeout: 60) + GC.disable + GC::Profiler.enable + GC.start + before = GC.stat(scope: :global) + GC::Profiler.disable + assert_equal before, GC.stat(scope: :global) + RUBY + end + + def test_stat_global_scope_profiler_clear_preserves_totals + omit 'default GC only' unless GC.config[:implementation] == 'default' + omit 'stress' if GC.stress + + assert_separately([], __FILE__, __LINE__, <<~'RUBY', timeout: 60) + GC.disable + GC::Profiler.enable + GC.start + before = GC.stat(scope: :global) + GC::Profiler.clear + assert_equal before, GC.stat(scope: :global) + RUBY + end + + def test_stat_global_scope_profiler_configure_preserves_totals + omit 'default GC only' unless GC.config[:implementation] == 'default' + omit 'stress' if GC.stress + + assert_separately([], __FILE__, __LINE__, <<~'RUBY', timeout: 60) + GC.disable + GC::Profiler.enable + 3.times { GC.start } + before = GC.stat(scope: :global) + GC::Profiler.configure(max_records: 2) + assert_equal before, GC.stat(scope: :global) + RUBY + end + + def test_stat_global_scope_retains_finished_ractor_history + omit 'default GC only' unless GC.config[:implementation] == 'default' + omit 'stress' if GC.stress + + assert_separately([], __FILE__, __LINE__, <<~'RUBY', timeout: 60) + Warning[:experimental] = false + GC.disable + local_before = GC.stat(:count) + process_before = GC.stat(:count, scope: :global) + ready = Ractor::Port.new + worker = Ractor.new(ready) do |reply| + GC.disable + 3.times { GC.start(full_mark: false, immediate_mark: true, immediate_sweep: true) } + control = Ractor::Port.new + reply << [GC.stat(:count), control] + control.receive + end + + worker_count, control = ready.receive + raise "worker count" unless worker_count == 3 + live = GC.stat(:count, scope: :global) + raise "live work missing" unless live - process_before == 3 + raise "worker changed main count" unless GC.stat(:count) == local_before + + monitor = Ractor::Port.new + worker.monitor(monitor) + control << :finish + raise "worker did not exit" unless monitor.receive == [worker, :exited] + raise "history lost on exit" unless GC.stat(:count, scope: :global) == live + + global_before = GC.stat(:count, scope: :global) + GC.start(full_mark: true, immediate_mark: true, immediate_sweep: true) + raise "global count" unless GC.stat(:count, scope: :global) - global_before == 1 + local_after_global = GC.stat(:count) + + snapshot = GC.stat(scope: :global) + raise "worker result" unless worker.value == :finish + raise "absorption changed history" unless GC.stat(scope: :global) == snapshot + raise "absorption changed main count" unless GC.stat(:count) == local_after_global + RUBY + end + + def test_stat_global_scope_preserves_nested_ractor_history + omit 'default GC only' unless GC.config[:implementation] == 'default' + omit 'stress' if GC.stress + + assert_separately([], __FILE__, __LINE__, <<~'RUBY', timeout: 60) + Warning[:experimental] = false + GC.disable + local_before = GC.stat(:count) + process_before = GC.stat(:count, scope: :global) + ready = Ractor::Port.new + outer = Ractor.new(ready) do |reply| + GC.disable + 3.times { GC.start(full_mark: false, immediate_mark: true, immediate_sweep: true) } + inner = Ractor.new do + GC.disable + 5.times { GC.start(full_mark: false, immediate_mark: true, immediate_sweep: true) } + GC.stat(:count) + end + raise "inner count" unless inner.value == 5 + reply << :ready + Ractor.receive + end + + ready.receive + raise "nested history missing" unless GC.stat(:count, scope: :global) - process_before == 8 + outer.send(:finish) + raise "outer result" unless outer.value == :finish + raise "nested history changed" unless GC.stat(:count, scope: :global) - process_before == 8 + raise "main inherited nested counts" unless GC.stat(:count) == local_before + RUBY + end + + def test_stat_global_scope_counts_global_collection_once + omit 'default GC only' unless GC.config[:implementation] == 'default' + omit 'stress' if GC.stress + + assert_separately([], __FILE__, __LINE__, <<~'RUBY', timeout: 60) + Warning[:experimental] = false + GC.disable + ready = Ractor::Port.new + worker = Ractor.new(ready) do |reply| + GC.disable + reply << :ready + Ractor.receive + end + ready.receive + + before = GC.stat(scope: :global) + GC.start(full_mark: true, immediate_mark: true, immediate_sweep: true) + after = GC.stat(scope: :global) + assert_equal 1, after[:count] - before[:count] + assert_equal 1, after[:major_gc_count] - before[:major_gc_count] + assert_equal before[:minor_gc_count], after[:minor_gc_count] + + worker.send(:finish) + assert_equal :finish, worker.value + RUBY + end + + def test_stat_global_scope_reads_are_coherent_during_ractor_collection + omit 'default GC only' unless GC.config[:implementation] == 'default' + assert_separately([], __FILE__, __LINE__, <<~'RUBY', timeout: 60) + Warning[:experimental] = false + GC.disable + keys = %i[count minor_gc_count major_gc_count time marking_time sweeping_time] + start_count = GC.stat(:count, scope: :global) + ready = Ractor::Port.new + + worker = Ractor.new(ready) do |reply| + GC.disable + control = Ractor::Port.new + reply << control + control.receive + 50.times { GC.start(full_mark: false, immediate_mark: true, immediate_sweep: true) } + :done + end + worker_control = ready.receive + + reader = Ractor.new(ready, keys) do |reply, ks| + control = Ractor::Port.new + reply << control + control.receive + previous = nil + read = lambda do + stat = GC.stat(scope: :global) + raise "count invariant" unless stat[:count] == stat[:minor_gc_count] + stat[:major_gc_count] + raise "time rounding" unless (0..1).cover?(stat[:time] - (stat[:marking_time] + stat[:sweeping_time])) + ks.each { |key| raise "decreasing #{key}" if previous && stat[key] < previous[key] } + previous = stat + end + 50.times { read.call } + reply << :halfway + control.receive + 50.times { read.call } + previous + end + reader_control = ready.receive + + worker_control << :go + reader_control << :go + raise "reader did not reach halfway" unless ready.receive == :halfway + assert_equal :done, worker.value + reader_control << :continue + reader_last = reader.value + + final = GC.stat(scope: :global) + assert_equal final[:minor_gc_count] + final[:major_gc_count], final[:count] + assert_include 0..1, final[:time] - (final[:marking_time] + final[:sweeping_time]) + assert reader_last.all? { |key, value| final[key] >= value } + assert_operator final[:count] - start_count, :>=, 50 + RUBY + end + + def test_stat_global_scope_preserves_measured_time_when_measurement_disabled + omit 'default GC only' unless GC.config[:implementation] == 'default' + omit 'stress' if GC.stress + + assert_separately([], __FILE__, __LINE__, <<~'RUBY', timeout: 60) + Warning[:experimental] = false + GC.disable + GC.measure_total_time = false + baseline = GC.stat(scope: :global) + ready = Ractor::Port.new + worker = Ractor.new(ready) do |reply| + GC.disable + GC.measure_total_time = true + retained = Array.new(100_000) { Object.new } + time_before = GC.stat(:time) + 100.times do + break if GC.stat(:time) - time_before >= 2 + GC.start(full_mark: true, immediate_mark: true, immediate_sweep: true) + end + raise "measured time not reached" unless GC.stat(:time) - time_before >= 2 + + GC.measure_total_time = false + control = Ractor::Port.new + reply << [GC.stat(:count), GC.stat(:time), GC.stat(:marking_time), GC.stat(:sweeping_time), control] + control.receive + 3.times { GC.start(full_mark: true, immediate_mark: true, immediate_sweep: true) } + [GC.stat(:count), GC.stat(:time), GC.stat(:marking_time), GC.stat(:sweeping_time), retained.length] + end + + worker_count, worker_time, worker_marking, worker_sweeping, control = ready.receive + before = GC.stat(scope: :global) + assert_equal worker_count, before[:count] - baseline[:count] + assert_operator worker_time, :>=, 2 + assert_include worker_time..worker_time + 1, before[:time] - baseline[:time] + + control << :continue + after_count, after_time, after_marking, after_sweeping, retained_count = worker.value + assert_equal [3, 100_000], [after_count - worker_count, retained_count] + assert_equal [worker_time, worker_marking, worker_sweeping], [after_time, after_marking, after_sweeping] + + after = GC.stat(scope: :global) + assert_equal 3, after[:count] - before[:count] + assert_equal before.values_at(:time, :marking_time, :sweeping_time), + after.values_at(:time, :marking_time, :sweeping_time) + RUBY + end + + def test_stat_global_scope_fork_inherits_archived_and_live_history + omit 'fork not supported' unless Process.respond_to?(:fork) + omit 'default GC only' unless GC.config[:implementation] == 'default' + omit 'stress' if GC.stress + assert_separately([], __FILE__, __LINE__, <<~'RUBY', timeout: 60) + Warning[:experimental] = false + GC.disable + process_before = GC.stat(:count, scope: :global) + + absorbed = Ractor.new do + GC.disable + 3.times { GC.start(full_mark: false, immediate_mark: true, immediate_sweep: true) } + GC.stat(:count) + end + assert_equal 3, absorbed.value + + live_ready = Ractor::Port.new + live = Ractor.new(live_ready) do |r| + GC.disable + 2.times { GC.start(full_mark: false, immediate_mark: true, immediate_sweep: true) } + r << GC.stat(:count) + Ractor.receive + end + raise "live count" unless live_ready.receive == 2 + + snapshot = GC.stat(scope: :global) + assert_equal 5, snapshot[:count] - process_before + read, write = IO.pipe + pid = Process.fork do + read.close + child_initial = GC.stat(scope: :global) + write.write(Marshal.dump(child_initial)) + GC.start(full_mark: true, immediate_mark: true, immediate_sweep: true) + child_after = GC.stat(scope: :global) + write.write(Marshal.dump(child_after)) + write.close + exit!(0) + end + write.close + child_initial = Marshal.load(read) + child_after = Marshal.load(read) + read.close + _, status = Process.waitpid2(pid) + raise "child exit status" unless status.success? + assert_equal snapshot, child_initial + assert_equal 1, child_after[:count] - child_initial[:count] + assert_equal snapshot[:count], GC.stat(:count, scope: :global) + + live.send(:finish) + assert_equal :finish, live.value + RUBY + end + + def test_stat_scope_selects_ractor_or_global_and_validates_options + omit 'default GC only' unless GC.config[:implementation] == 'default' + omit 'stress' if GC.stress + assert_separately([], __FILE__, __LINE__, <<~'RUBY', timeout: 60) + Warning[:experimental] = false + GC.disable + global_keys = %i[count minor_gc_count major_gc_count time marking_time sweeping_time] + local_gauge = :heap_live_slots + + default = GC.stat + assert_include default.keys, local_gauge + [:ractor, :local].each do |scope| + explicit = GC.stat(scope: scope) + assert_not_same default, explicit + assert_equal default.values_at(*global_keys), explicit.values_at(*global_keys) + assert_include explicit.keys, local_gauge + assert_equal default[:count], GC.stat(:count, scope: scope) + assert_equal default[:count], GC.stat(nil, scope: scope)[:count] + + buffer = { scope: :global, sentinel: :keep, count: -1 } + assert_same buffer, GC.stat(buffer, scope: scope) + assert_equal [:global, :keep, default[:count]], buffer.values_at(:scope, :sentinel, :count) + assert_kind_of Integer, buffer[local_gauge] + assert_raise(FrozenError) { GC.stat({}.freeze, scope: scope) } + end + + global = GC.stat(scope: :global) + assert_equal global_keys.sort, global.keys.sort + assert_raise(ArgumentError) { GC.stat(local_gauge, scope: :global) } + assert_equal global[:count], GC.stat(:count, scope: :global) + + [:process, :thread, "global", "ractor", "local", nil, true].each do |scope| + assert_raise(ArgumentError) { GC.stat(scope: scope) } + end + assert_raise(ArgumentError) { GC.stat(foo: :bar) } + + buffer = { scope: :global, sentinel: :keep } + assert_same buffer, GC.stat(buffer) + assert_equal :global, buffer[:scope] + assert_equal :keep, buffer[:sentinel] + assert_kind_of Integer, buffer[local_gauge] + + out = { scope: :local, sentinel: :ok, count: -1 } + assert_same out, GC.stat(out, scope: :global) + assert_equal :local, out[:scope] + assert_equal :ok, out[:sentinel] + assert_equal global_keys.sort, (out.keys - [:scope, :sentinel]).sort + assert_equal GC.stat(:count, scope: :global), out[:count] + + assert_raise(FrozenError) { GC.stat({}.freeze, scope: :global) } + RUBY + end + + def test_stat_global_scope_is_unsupported_by_non_default_gc + omit 'skipped on default GC' if GC.config[:implementation] == 'default' + + assert_kind_of Hash, GC.stat + assert_kind_of Integer, GC.stat(:count) + [:ractor, :local].each do |scope| + assert_kind_of Hash, GC.stat(scope: scope) + assert_kind_of Integer, GC.stat(:count, scope: scope) + end + assert_raise(NotImplementedError) { GC.stat(scope: :global) } + assert_raise(NotImplementedError) { GC.stat(:count, scope: :global) } + assert_raise(NotImplementedError) { GC.stat({}, scope: :global) } + end + + def test_gc_start_ractor_global_false + omit "no GC.stat(:global_gc_count)" unless GC.stat.key?(:global_gc_count) + assert_ractor(<<~'RUBY') + r = Ractor.new { Ractor.receive } + before = GC.stat(:global_gc_count) + GC.start(global: false) + after = GC.stat(:global_gc_count) + assert_equal 0, after - before + r.send(:done) + RUBY + end + + def test_gc_start_ractor_global_true + omit "no GC.stat(:global_gc_count)" unless GC.stat.key?(:global_gc_count) + assert_ractor(<<~'RUBY') + r = Ractor.new { Ractor.receive } + [{global: true}, {}].each do |opts| + before = GC.stat(:global_gc_count) + GC.start(**opts) + after = GC.stat(:global_gc_count) + assert_operator after - before, :>=, 1 + end + r.send(:done) + RUBY + end end From 6cc4ec3405b15936b04e87ffe8c14a91f846dc4c Mon Sep 17 00:00:00 2001 From: Matt Valentine-House Date: Wed, 23 Sep 2026 14:36:54 +0100 Subject: [PATCH 7/9] Add a per-Ractor GC timing example --- sample/gc_stat_ractors.rb | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 sample/gc_stat_ractors.rb diff --git a/sample/gc_stat_ractors.rb b/sample/gc_stat_ractors.rb new file mode 100644 index 00000000000000..a7490eaa611276 --- /dev/null +++ b/sample/gc_stat_ractors.rb @@ -0,0 +1,30 @@ +Warning[:experimental] = false +GC.measure_total_time = true + +workers = 4.times.map do |worker_id| + Ractor.new(worker_id) do |id| + Ractor.receive # Wait until all workers have been created. + 20.times do |batch| + Array.new(10_000) { |i| "#{id}:#{batch}:#{i}".reverse }.sort! + end + GC.start(full_mark: false) + { + ractor: "worker-#{id}", + marking_time_ms: GC.stat(:marking_time, scope: :ractor), + sweeping_time_ms: GC.stat(:sweeping_time, scope: :ractor) + } + end +end + +workers.each { |worker| worker.send(:start) } +workers.each { |worker| p worker.value } +p({ + ractor: "main", + marking_time_ms: GC.stat(:marking_time, scope: :ractor), + sweeping_time_ms: GC.stat(:sweeping_time, scope: :ractor) +}) + +puts "process-wide GC statistics:" +p GC.stat(scope: :global) +puts "process marking time (ms): #{GC.stat(:marking_time, scope: :global)}" +puts "process sweeping time (ms): #{GC.stat(:sweeping_time, scope: :global)}" From c6ff4670fcf30b2e3d85813eafc21b0f95f03c63 Mon Sep 17 00:00:00 2001 From: Matt Valentine-House Date: Thu, 24 Sep 2026 17:49:13 +0100 Subject: [PATCH 8/9] Update the zjit hir tests for new GC parameter --- zjit/src/hir/tests.rs | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/zjit/src/hir/tests.rs b/zjit/src/hir/tests.rs index 4d7474ca45910a..79e820a52866ce 100644 --- a/zjit/src/hir/tests.rs +++ b/zjit/src/hir/tests.rs @@ -5702,22 +5702,24 @@ pub(crate) mod hir_build_tests { v3:BasicObject = LoadField v2, :full_mark@0x1000 v4:BasicObject = LoadField v2, :immediate_mark@0x1001 v5:BasicObject = LoadField v2, :immediate_sweep@0x1002 - v6:BasicObject = LoadField v2, :@0x1003 - Jump bb3(v1, v3, v4, v5, v6) + v6:BasicObject = LoadField v2, :global@0x1003 + v7:BasicObject = LoadField v2, :@0x1004 + Jump bb3(v1, v3, v4, v5, v6, v7) bb2(): EntryPoint JIT(0) - v9:BasicObject = LoadArg :self@0 - v10:BasicObject = LoadArg :full_mark@1 - v11:BasicObject = LoadArg :immediate_mark@2 - v12:BasicObject = LoadArg :immediate_sweep@3 - v13:CPtr = GetEP 0 - v14:BasicObject = LoadField v13, :@0x1004 - Jump bb3(v9, v10, v11, v12, v14) - bb3(v16:BasicObject, v17:BasicObject, v18:BasicObject, v19:BasicObject, v20:BasicObject): - v27:FalseClass = Const Value(false) - v29:BasicObject = InvokeBuiltin gc_start_internal, v16, v17, v18, v19, v27 + v10:BasicObject = LoadArg :self@0 + v11:BasicObject = LoadArg :full_mark@1 + v12:BasicObject = LoadArg :immediate_mark@2 + v13:BasicObject = LoadArg :immediate_sweep@3 + v14:BasicObject = LoadArg :global@4 + v15:CPtr = GetEP 0 + v16:BasicObject = LoadField v15, :@0x1005 + Jump bb3(v10, v11, v12, v13, v14, v16) + bb3(v18:BasicObject, v19:BasicObject, v20:BasicObject, v21:BasicObject, v22:BasicObject, v23:BasicObject): + v30:FalseClass = Const Value(false) + v33:BasicObject = InvokeBuiltin gc_start_internal, v18, v19, v20, v21, v30, v22 CheckInterrupts - Return v29 + Return v33 "); } From d3f0a612d348635291699f614f22125b396b869b Mon Sep 17 00:00:00 2001 From: Nobuyoshi Nakada Date: Fri, 25 Sep 2026 16:58:51 +0900 Subject: [PATCH 9/9] Fix up for wasm `stack_maxsize` is unknown on wasm and set to 0. --- gc.c | 1 + 1 file changed, 1 insertion(+) diff --git a/gc.c b/gc.c index 1f087367c71a82..d1bf86e2bb1b35 100644 --- a/gc.c +++ b/gc.c @@ -2880,6 +2880,7 @@ stack_check(rb_execution_context_t *ec, int water_mark) SET_STACK_END; size_t length = STACK_LENGTH; + if (STACK_LEVEL_MAX == 0) return FALSE; /* unknown maxsize */ if (STACK_LEVEL_MAX <= (size_t)water_mark) return TRUE; size_t maximum_length = STACK_LEVEL_MAX - water_mark;