Skip to content

Commit b84190e

Browse files
committed
ZJIT: Pass a &blk argument through to a direct send
`bar(&blk)` only became a direct send when ZJIT could prove `blk` was nil. Anything else kept the site on `rb_vm_send`, which pays a method search, an interpreter frame push and a `vm_exec` setjmp on every call. On lobsters that is 2.06M of the 12.0M dynamic sends a run -- 17% of them -- concentrated in Rails' `def _read_attribute(name, &block) = @attributes.fetch_value(name, &block)` style forwarding methods. `vm_caller_setup_arg_block` produces the callee's block handler from the block argument, and two of its cases need no work at run time: * A `Proc` is its own block handler, so a guard on the argument's class is enough to write it into the callee frame's specval. * `rb_block_param_proxy` resolves to `VM_CF_BLOCK_HANDLER(cfp)`, this frame's own block handler, which is one load off the local EP. That is the case a `def foo(&blk) = bar(&blk)` site compiles to, because the block parameter is only ever read as `&blk`. The proxy reaches the send through the join of `getblockparamproxy`'s modified/unmodified branches, so its identity is not visible at the send. Branch on it there instead: `getblockparamproxy` records the value it pushed for this frame's local EP, and a site that profiled the proxy's (singleton) class gets an arm that refines the argument to the proxy object, which `type_specialize` then turns into the EP load. The other side of the branch is a block parameter that `setblockparam` materialized, which is a legitimate value to reach the site, so it keeps the dynamic send. `SendDirect` grows a `block_arg` operand for the resulting handler, and the inliner declines any call that has one: inlining does not push the callee frame whose specval would carry it. On lobsters (10 warmup + 5 bench iterations): dynamic_send_count 12,001,057 -> 10,527,202 (-12.3%) send_polymorphic 1,324,915 -> 466,975 send_block_arg_not_nil 692,187 -> 57,695 iseq_optimized_send_count 29,307,998 -> 30,822,668 [zjit/min port note] emit_polymorphic_send() ported without the per-arm profiled-shape machinery (ARM_SHAPE_MIN_SHARE / as_polymorphic_arm / record_profiled_type) from excluded commits, so each arm drops the receiver's profile entry the way master's gen_send_chain does. Keeps master's try_inline_send_direct() entry point, its `cd` argument to gen_send_iseq_direct(), and its 2-arg gen_block_handler_specval(); SendDirectData gains only `block_arg`, not the excluded `guard_state`.
1 parent 06770cb commit b84190e

7 files changed

Lines changed: 702 additions & 132 deletions

File tree

zjit/bindgen/src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ fn main() {
188188
.allowlist_var("rb_cSymbol")
189189
.allowlist_var("rb_cFloat")
190190
.allowlist_var("rb_cNumeric")
191+
.allowlist_var("rb_cProc")
191192
.allowlist_var("rb_cRange")
192193
.allowlist_var("rb_cString")
193194
.allowlist_var("rb_cThread")

zjit/src/codegen.rs

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -695,11 +695,12 @@ fn gen_insn(cb: &mut CodeBlock, jit: &mut JITState, asm: &mut Assembler, functio
695695
&Insn::Send { cd, block: Some(BlockHandler::BlockArg), state, reason, .. } => gen_send(jit, asm, function, cd, std::ptr::null(), &function.frame_state(state), reason),
696696
&Insn::SendForward { cd, blockiseq, state, reason, .. } => gen_send_forward(jit, asm, function, cd, blockiseq, &function.frame_state(state), reason),
697697
Insn::SendDirect(insn) => {
698-
let SendDirectData { cd, cme, iseq, recv, args, kw_bits, jit_entry_idx, block, state, .. } = &**insn;
698+
let SendDirectData { cd, cme, iseq, recv, args, kw_bits, jit_entry_idx, block, block_arg, state, .. } = &**insn;
699+
let block_arg = block_arg.map(|block_arg| opnd!(block_arg));
699700
gen_send_iseq_direct(
700701
cb, jit, asm,
701702
function, *cd, *cme, *iseq, opnd!(recv), opnds!(args),
702-
*kw_bits, *jit_entry_idx, &function.frame_state(*state), *block,
703+
*kw_bits, *jit_entry_idx, &function.frame_state(*state), *block, block_arg,
703704
)
704705
}
705706
Insn::PushInlineFrame { cme, iseq, recv, num_args, blockiseq, captured, state } => {
@@ -2159,6 +2160,7 @@ fn gen_send_iseq_direct(
21592160
jit_entry_idx: u16,
21602161
state: &FrameState,
21612162
block: Option<BlockHandler>,
2163+
block_arg: Option<lir::Opnd>,
21622164
) -> lir::Opnd {
21632165
gen_incr_counter(asm, Counter::iseq_optimized_send_count);
21642166

@@ -2181,11 +2183,16 @@ fn gen_send_iseq_direct(
21812183
gen_spill_locals(jit, asm, state);
21822184
asm.stack_map(stack_map, jit_frame, state.depth);
21832185

2184-
// This mirrors vm_caller_setup_arg_block() in for the `blockiseq != NULL` case.
2185-
// The HIR specialization guards ensure we will only reach here for literal blocks,
2186-
// not &block forwarding, &:foo, etc. Thise are rejected in `type_specialize` by
2187-
// `unspecializable_call_type`.
2188-
let block_handler = block.map(|bh| match bh { BlockHandler::BlockIseq(b) => gen_block_handler_specval(asm, b), BlockHandler::BlockArg => unreachable!("BlockArg in gen_send_iseq_direct") });
2186+
// This mirrors vm_caller_setup_arg_block(): `block` is its `blockiseq != NULL` case, and
2187+
// `block_arg` is a `&blk` argument that `type_specialize` reduced to the block handler that
2188+
// function would have produced -- the Proc itself, or this frame's own handler for the block
2189+
// param proxy. Every other kind of block argument, `&:foo` or anything with a `to_proc`,
2190+
// keeps the dynamic send.
2191+
let block_handler = match block {
2192+
Some(BlockHandler::BlockIseq(b)) => Some(gen_block_handler_specval(asm, b)),
2193+
Some(BlockHandler::BlockArg) => unreachable!("BlockArg in gen_send_iseq_direct"),
2194+
None => block_arg,
2195+
};
21892196

21902197
let callee_is_bmethod = VM_METHOD_TYPE_BMETHOD == unsafe { get_cme_def_type(cme) };
21912198

zjit/src/codegen_tests.rs

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2312,6 +2312,159 @@ fn test_send_nil_block_arg() {
23122312
"), @"false");
23132313
}
23142314

2315+
#[test]
2316+
fn test_attr_reader_two_shapes_per_class_in_polymorphic_arm() {
2317+
// A polymorphic call site branches on the receiver's class, so one arm can be entered by
2318+
// objects of that class with different shapes. Each shape the profile saw gets its own ivar
2319+
// load; the rest still have to read correctly through the C fallback.
2320+
assert_snapshot!(inspect("
2321+
class C
2322+
attr_reader :foo
2323+
def early = (@foo = 1; @bar = 2)
2324+
def late = (@bar = 3; @foo = 4)
2325+
def third = (@baz = 5; @qux = 6; @foo = 7)
2326+
end
2327+
class D
2328+
attr_reader :foo
2329+
def initialize = @foo = :d
2330+
end
2331+
objs = []
2332+
200.times do |i|
2333+
c = C.new
2334+
case i % 3
2335+
when 0 then c.early
2336+
when 1 then c.late
2337+
else c.third
2338+
end
2339+
objs << c
2340+
objs << D.new
2341+
end
2342+
def read(o) = o.foo
2343+
out = objs.map { |o| read(o) }
2344+
[out.tally.sort_by(&:to_s), read(C.new)]
2345+
"), @"[[[1, 67], [4, 67], [7, 66], [:d, 200]], nil]");
2346+
}
2347+
2348+
#[test]
2349+
fn test_send_mixed_nil_and_non_nil_block_arg() {
2350+
// A `&block` forwarding site that sees both nil and non-nil blocks is split on nil, so the
2351+
// no-block calls become direct sends. Both branches must still produce the right answer.
2352+
assert_snapshot!(inspect("
2353+
def callee(n, &block)
2354+
block ? block.call(n) : n
2355+
end
2356+
def forward(n, &block) = callee(n, &block)
2357+
results = []
2358+
100.times do |i|
2359+
results << forward(i)
2360+
results << forward(i) { |n| n * 2 }
2361+
end
2362+
[forward(7), forward(7) { |n| n + 1 }, results.last(2)]
2363+
"), @"[7, 8, [99, 198]]");
2364+
}
2365+
2366+
#[test]
2367+
fn test_send_nil_block_arg_split_polymorphic_receiver() {
2368+
// The nil branch of the split dispatches on the receiver type, so a forwarding site shared by
2369+
// several receiver classes still has to pick the right method for each.
2370+
assert_snapshot!(inspect("
2371+
class A; def value(&block) = block ? block.call(1) : 1; end
2372+
class B; def value(&block) = block ? block.call(2) : 2; end
2373+
def forward(obj, &block) = obj.value(&block)
2374+
out = []
2375+
200.times do |i|
2376+
obj = i.even? ? A.new : B.new
2377+
out << forward(obj)
2378+
out << forward(obj) { |n| n * 10 } if i % 3 == 0
2379+
end
2380+
[forward(A.new), forward(B.new), forward(A.new) { |n| n * 10 }, out.sum]
2381+
"), @"[1, 2, 10, 1300]");
2382+
}
2383+
2384+
#[test]
2385+
fn test_send_forwards_block_param_proxy() {
2386+
// `bar(&blk)` where `blk` comes from `getblockparamproxy` passes this frame's own block
2387+
// handler to the callee, so the callee's `yield` and `&b` parameter have to see the block
2388+
// the outermost caller gave. Every kind of handler goes through the same site: a literal
2389+
// block, no block at all, a Proc, and a symbol-to-proc.
2390+
assert_snapshot!(inspect("
2391+
def callee(n, &b)
2392+
[n, block_given? ? yield(n) : nil, b ? b.call(n) : nil]
2393+
end
2394+
def forward(n, &blk) = callee(n, &blk)
2395+
def pass_proc(n, p) = forward(n, &p)
2396+
out = []
2397+
200.times do |i|
2398+
out << forward(i) { |x| x + 1 }
2399+
out << forward(i)
2400+
out << pass_proc(i, ->(x) { x * 2 })
2401+
out << pass_proc(i, nil)
2402+
end
2403+
out.last(4)
2404+
"), @"[[199, 200, 200], [199, nil, nil], [199, 398, 398], [199, nil, nil]]");
2405+
}
2406+
2407+
#[test]
2408+
fn test_send_forwards_block_param_proxy_after_setblockparam() {
2409+
// Assigning the block parameter makes `getblockparamproxy` hand back the materialized Proc
2410+
// instead of the proxy, so the branch on the proxy has to send those calls the other way.
2411+
assert_snapshot!(inspect("
2412+
def callee(n, &b) = [n, b ? b.call(n) : nil]
2413+
def forward(n, replace, &blk)
2414+
blk = ->(x) { x * 100 } if replace
2415+
callee(n, &blk)
2416+
end
2417+
out = []
2418+
200.times do |i|
2419+
out << forward(i, false) { |x| x + 1 }
2420+
out << forward(i, true) { |x| x + 1 }
2421+
end
2422+
out.last(2)
2423+
"), @"[[199, 200], [199, 19900]]");
2424+
}
2425+
2426+
#[test]
2427+
fn test_send_proc_block_arg_passes_through() {
2428+
// A `&blk` argument holding a plain Proc is its own block handler in the interpreter, so the
2429+
// direct send installs it as the callee frame's specval.
2430+
assert_snapshot!(inspect("
2431+
def callee(n, &b) = [n, block_given?, b.call(n)]
2432+
def entry(n, p) = callee(n, &p)
2433+
doubler = ->(x) { x * 2 }
2434+
out = nil
2435+
200.times { |i| out = entry(i, doubler) }
2436+
[out, entry(5, proc { |x| x + 1 })]
2437+
"), @"[[199, true, 398], [5, true, 6]]");
2438+
}
2439+
2440+
#[test]
2441+
fn test_send_proc_block_arg_guard_rejects_other_block_args() {
2442+
// The Proc guard has to send a `&:sym` or a Method through the interpreter, which converts it
2443+
// with `to_proc` rather than using it as the block handler directly.
2444+
assert_snapshot!(inspect("
2445+
def callee(n, &b) = b.call(n)
2446+
def entry(n, p) = callee(n, &p)
2447+
doubler = ->(x) { x * 2 }
2448+
200.times { |i| entry(i, doubler) }
2449+
[entry(5, doubler), entry(-6, :abs), entry(7, 2.method(:+))]
2450+
"), @"[10, 6, 9]");
2451+
}
2452+
2453+
#[test]
2454+
fn test_send_block_param_proxy_from_block_body() {
2455+
// `foo(&blk)` inside a block reads the block parameter of the enclosing method, and
2456+
// `VM_CF_BLOCK_HANDLER` resolves through the local EP to that same frame.
2457+
assert_snapshot!(inspect("
2458+
def callee(n, &b) = [n, b ? b.call(n) : nil]
2459+
def forward(n, &blk)
2460+
[1].map { callee(n, &blk) }.first
2461+
end
2462+
out = nil
2463+
200.times { |i| out = forward(i) { |x| x + 3 } }
2464+
[out, forward(9)]
2465+
"), @"[[199, 202], [9, nil]]");
2466+
}
2467+
23152468
#[test]
23162469
fn test_send_symbol_block_arg() {
23172470
assert_snapshot!(inspect("

zjit/src/cruby_bindings.inc.rs

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)