Feature #22523
opendefine_method should make its body shareable when possible, using Ractor.shareable_proc semantics
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:
-
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_methodwould change the behavior of existing code like: -
block_given?,iterator?,defined?(yield)andsuperwithout an explicit block use the block of the enclosing method, likeyield, but were not rejected: #22522, https://github.com/ruby/ruby/pull/19803. Without that fix,define_methodwould change the behavior of existing code like: -
A block parameter (
&b) of the enclosing method which was not read yet was seen asnil: #22525, https://github.com/ruby/ruby/pull/19808. Without that fix,define_methodwould break existing code like this, from rspec-support (and similar code in ActiveRecord):
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/bindingreassigning a captured variable cannot be detected statically. Similarly,binding/evalinside 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 calleddefine_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 aThread.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_methodmethods defined in the same scope already share them through the defining frame, so this extends that sharing to all shareable methods.define_methoditself is slightly slower, because it checks whether the body can be shared: about +30 ns perdefine_methodwhen 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.
Updated by Eregon (Benoit Daloze) 1 day ago
- Related to Feature #22444: Ractor.new should use Ractor.shareable_proc semantics for the block added
- Related to Feature #21557: Ractor.shareable_proc to make sharable Proc objects, safely and flexibly added
Updated by Eregon (Benoit Daloze) 1 day ago
- Subject changed from `define_method` should make its body shareable when possible, using `Ractor.shareable_proc` semantics to define_method should make its body shareable when possible, using Ractor.shareable_proc semantics
Updated by Eregon (Benoit Daloze) 1 day ago
- Description updated (diff)
Updated by Eregon (Benoit Daloze) 1 day ago
- Description updated (diff)
Updated by Eregon (Benoit Daloze) about 21 hours ago
- Description updated (diff)