Project

General

Profile

Actions

Bug #22488

closed

Defining a method in a module that many classes include is ~4x slower in Ruby 4.0 than in 3.4

Bug #22488: Defining a method in a module that many classes include is ~4x slower in Ruby 4.0 than in 3.4

Added by linyiru (Lawrence Lin) 6 days ago. Updated 6 days ago.

Status:
Closed
Assignee:
-
Target version:
-
ruby -v:
4.0.9 (2026-10-23 revision 3e93b23afc) +PRISM [x86_64-linux]
[ruby-core:127283]

Description

Summary

This affects the released 4.0 series (4.0.0 to 4.0.9), and is already fixed on master.

In Ruby 4.0, defining a method in a module costs more for every class that includes it than in Ruby 3.4: with 500 including classes, defining 2,000 methods takes 77 ms on 4.0.9 against 20 ms on 3.4.12. Master is back to 20 ms since 14eb0831527b8e1c5bbfef21ecaed14b21103315 ("Speedup repeated calls to rb_gccct_clear_table"), which isn't on ruby_4_0. It applies cleanly there and brings 4.0 back to the 3.4 numbers.

This pattern is common in Rails applications: the route helper modules are included in every controller, view and mailer by the time the routes are drawn, and each named route defines two methods in them.

Reproduction

def measure(includers, methods: 2000)
  rd, wr = IO.pipe
  pid = fork do
    rd.close
    mod = Module.new
    base = Class.new { def warm; end }
    classes = includers.times.map do
      c = Class.new(base) { include mod }
      c.new.warm
      c
    end
    t = Process.clock_gettime(Process::CLOCK_MONOTONIC)
    methods.times { |i| mod.define_method(:"m#{i}") { i } }
    wr.write(((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t) * 1000).to_s)
    exit!(0)
  end
  wr.close
  Process.wait(pid)
  rd.read.to_f
end

[0, 100, 500].each do |n|
  puts format("%3d including classes: %.1f ms", n, 3.times.map { measure(n) }.min)
end

The classes need to stay referenced: if they are collected during the loop, the slowdown disappears.

Results

The script above on x86_64 Linux (Ryzen 7 8745HS), with the official ruby Docker images:

0 including classes 100 500
3.4.12 5.0 ms 7.3 ms 19.5 ms
4.0.9 4.4 ms 18.7 ms 76.5 ms
4.1.0-preview1 2.1 ms 5.4 ms 19.2 ms

4.0.0 gives the same numbers as 4.0.9 (77 ms with 500 including classes). With miniruby built from ruby_4_0 (9bebcb455f), the same measurement gives 77 ms, and 20 ms with 14eb0831 cherry-picked.

Bisecting with miniruby:

  • The slowdown starts at 382645d440d5da66a0c04557f3ff2ca226de3a27 ("namespace on read"): 16 ms on its parent, 78 ms on it, with 500 including classes.
  • It ends at 14eb0831527b8e1c5bbfef21ecaed14b21103315 ("Speedup repeated calls to rb_gccct_clear_table"): 78 ms on its parent, 21 ms on it.

git cherry-pick 14eb0831 applies cleanly on ruby_4_0 (5 files, 11 insertions, 7 deletions).

Impact on a Rails application

Booting Mastodon (Rails 8.1.4, 605 named routes, the route helpers included in 643 classes and modules) in production, on Ruby 4.0.9 built from source with the same configure flags as the official image, with and without 14eb0831, 7 alternating runs:

4.0.9 4.0.9 + 14eb0831
Boot, median 2087 ms 2044 ms
Drawing the routes, median 205 ms 188 ms

The boot improves by more than the route drawing, as every method defined while booting is cheaper.

Since the fix is small and applies cleanly, could it be considered for backporting to 4.0?

Actions

Also available in: PDF Atom