Project

General

Profile

Actions

Feature #22523

open

define_method should make its body shareable when possible, using Ractor.shareable_proc semantics

Feature #22523: define_method should make its body shareable when possible, using Ractor.shareable_proc semantics

Added by Eregon (Benoit Daloze) about 21 hours ago. Updated about 18 hours ago.

Status:
Open
Assignee:
-
Target version:
-
[ruby-core:127383]

Description

#22444 made Ractor.new use the Ractor.shareable_proc semantics from #21557 for its block, and there was some interest in doing the same for define_method (#22444#note-5).
That was in fact already proposed in the define_method section of #21557.

Proposal

Module#define_method and Kernel#define_singleton_method try to make the given block/Proc shareable, as if by Ractor.shareable_proc(self: nil). If that is not possible, the block/Proc is used as before:

def define_method(name, &body)
  body = Ractor.shareable_proc(self: nil, &body) rescue body
  Primitive.define_method(name, &body)
end

self of the body does not matter, because define_method replaces it with the receiver on every call anyway.

Motivation

Ractor compatibility for free

Today, methods defined by define_method cannot be called from a non-main Ractor (defined with an un-shareable Proc in a different Ractor (RuntimeError)), unless the block is explicitly made shareable. That is verbose, and libraries need workarounds like this one in ostruct:

if defined?(::Ractor)
  getter_proc = nil.instance_eval{ Proc.new { @table[name] } }
  setter_proc = nil.instance_eval{ Proc.new {|x| @table[name] = x} }
  ::Ractor.make_shareable(getter_proc)
  ::Ractor.make_shareable(setter_proc)
else
  getter_proc = Proc.new { @table[name] }
  setter_proc = Proc.new {|x| @table[name] = x}
end
define_singleton_method!(name, &getter_proc)
define_singleton_method!("#{name}=", &setter_proc)

With this proposal, that becomes:

define_singleton_method!(name) { @table[name] }
define_singleton_method!("#{name}=") { |x| @table[name] = x }

Most define_method blocks only read outer variables which are assigned once and hold Symbols, frozen Strings, Integers, Classes, etc. All of those methods become callable from any Ractor with no code change. This makes it much easier for existing libraries to become Ractor-compatible.

Performance: captured variables can be folded at definition time

Once the body is shareable, its captured variables are immutable: they are never reassigned (checked statically), their values are shareable, and they are copied when the method is defined. So for a given method entry they are constants.
This allows JITs to fold reads of outer variables at definition time, making a define_method body as fast as the equivalent def:

class C
  x = 42
  def def_m = 42
  define_method(:dm_nocapture) { 42 }
  define_method(:dm_capture) { x } # with this proposal, a JIT can compile it like def_m
end

Time per call (macOS arm64, call threshold 1, loop of 20M calls):

def_m dm_nocapture dm_capture
YJIT 1.0 ns 1.0 ns 2.7 ns
ZJIT 1.0 ns 0.9 ns 2.3 ns

The only difference between dm_capture and def_m/dm_nocapture is that the captured variable is read through the environment. Neither JIT folds it yet: these numbers are the same with and without the patch. That folding is only valid if the variable cannot change, which is what this proposal guarantees. Without it, the JIT has to keep reading the variable through the environment, since the variable could be reassigned at any time.

Another possibility is to do the folding in the bytecode: when the method is defined, getlocal instructions reading captured variables could be replaced by putobject <value>. Both JITs already treat putobject operands as known constants, so they would benefit without JIT-specific changes. The interpreter would too, although there the read itself is cheap (one extra hop through the environment). The bigger gain would be from optimizations which can then use these constants. A trade-off is that the iseq of a block is shared by all Procs created from it (Ractor.shareable_proc copies the environment, not the iseq), so this needs a copy of the iseq per method, as Proc#refined already does. That copy could be made lazily, only for methods which are called often.

Compatibility

define_method never raises because of this: if the body cannot be made shareable (it reads a reassigned or unshareable outer variable, writes an outer variable, uses yield, ...), the method is defined exactly like before.

This relies on the static analysis of Ractor.shareable_proc rejecting every block whose semantics would change. While implementing this I found three cases where it did not, filed as separate bugs and to be fixed first:

  1. Outer variables assigned in a loop (while/until/for, retry, redo) were not considered reassigned: #22521, https://github.com/ruby/ruby/pull/19802. Without that fix, define_method would change the behavior of existing code like:

    class C
      for name in %i[a b]
        define_method(name) { name }
      end
    end
    [C.new.a, C.new.b] # => [:b, :b], it must not become [:a, :b]
    
  2. block_given?, iterator?, defined?(yield) and super without an explicit block use the block of the enclosing method, like yield, but were not rejected: #22522, https://github.com/ruby/ruby/pull/19803. Without that fix, define_method would change the behavior of existing code like:

    class D
      def foo = D.module_eval { define_method(:m) { block_given? } }
    end
    D.new.foo {}
    D.new.m # => true, it must not become false
    
  3. A block parameter (&b) of the enclosing method which was not read yet was seen as nil: #22525, https://github.com/ruby/ruby/pull/19808. Without that fix, define_method would break existing code like this, from rspec-support (and similar code in ActiveRecord):

    def self.define_optimized_require_for_rspec(lib, &require_relative)
      define_singleton_method("require_rspec_#{lib}") do |f|
        require_relative.call("#{lib}/#{f}") # require_relative would be nil
      end
    end
    

With these fixes, such bodies are not made shareable and the methods behave exactly as before.

Remaining differences, for methods whose body gets made shareable:

  • As discussed in #21557 (section "eval and binding"), eval/binding reassigning a captured variable cannot be detected statically. Similarly, binding/eval inside the method body (e.g. binding.irb) do not see the captured variables, as for any shareable Proc.
  • $~ and $_ in the method body are no longer shared with the frame which called define_method (which was more of a surprising behavior than a feature). Instead, as for any shareable Proc, they are stored per Fiber. Currently that is the slot also used by the root frame of the Fiber/Thread, so a call made directly in a Thread.new { }/Fiber.new { } block body can overwrite that block's $~. That is a bug of shareable Procs in general: #22524, https://github.com/ruby/ruby/pull/19804. Shareable methods still share $~/$_ with each other on the same Fiber. Today, define_method methods defined in the same scope already share them through the defining frame, so this extends that sharing to all shareable methods.
  • define_method itself is slightly slower, because it checks whether the body can be shared: about +30 ns per define_method when it falls back, and about +125 ns when it makes the body shareable (the environment is copied). This is a one-time cost per definition; calls are not slower.

Implementation

PR: https://github.com/ruby/ruby/pull/19801, on top of https://github.com/ruby/ruby/pull/19802, https://github.com/ruby/ruby/pull/19803, https://github.com/ruby/ruby/pull/19808 and https://github.com/ruby/ruby/pull/19804.

With module_function, the singleton method now uses the same body as the instance method, so both are shareable when possible.

make btest, make test-all and the relevant ruby/spec specs pass. The only btests/tests which needed changes were ones checking that a define_method body capturing nothing ({}, { self }) cannot be called from another Ractor. They now capture an unshareable object, so they keep testing that error.


Related issues 2 (0 open — 2 closed)

Related to Ruby - Feature #22444: Ractor.new should use Ractor.shareable_proc semantics for the block ClosedActions
Related to Ruby - Feature #21557: Ractor.shareable_proc to make sharable Proc objects, safely and flexiblyClosedko1 (Koichi Sasada)Actions
Actions

Also available in: PDF Atom