Skip to content

Commit 2aecabe

Browse files
committed
ZJIT: Compile direct sends to callees with a **rest parameter
`can_direct_send` rejected every callee with a `**rest` parameter, which on lobsters was 176k of the remaining `one_or_more_complex_arg_pass` send fallbacks -- the largest shape left after `def foo(...)` callees, because Rails option hashes are written as `**options` throughout. `**rest` is the keyword planning we already do plus one more argument. `args_setup_kw_parameters` fills the named keyword slots exactly as before and then hands `make_rest_kw_hash` whatever slots the table did not claim, in the order the caller wrote them, so the extras become one `NewHash` in the callee's kwrest slot. The Hash is allocated even when nothing is left over, which is also what makes `foo(1)` against `def foo(a, **opts)` -- the common Rails shape -- compile: `opts` is just an empty Hash. Relaxing the count and unknown-keyword checks for these callees is what lets the extras through in the first place; every required keyword is still matched by name, so a missing one keeps raising from the interpreter. Two callee modes stay on VM dispatch: `def foo(**)`, because `args_setup_kw_rest_parameter` leaves the anonymous slot nil instead of allocating an empty Hash when no keywords are passed, and `ruby2_keywords`, which needs the VM to carry RHASH_PASS_AS_KEYWORDS across the call. The argument list also has to give the hidden `kw_bits` slot an argument of its own when the callee has both named keywords and `**rest`: the parameter locals run `(lead, opt, rest, post, kw..., kw_bits, kwrest)`, so pushing the kwrest Hash right after the keywords lands it one local low, in `kw_bits`, where the bitmask store then overwrites it, and the real `**rest` local keeps whatever stale VALUE was on the VM stack. JIT-to-JIT calls pass arguments in registers, so this only broke when a function stub spilled the frame in local order and exited to the interpreter -- which is how it corrupted `**materialization_options` in bundler's lazy_specification.rb under the default call threshold in production. `kw_bits` therefore becomes a real argument for these callees, the caller's separate frame store goes away, and the JIT entry reads it as a parameter. Bisected and patched by River.
1 parent 7bbada3 commit 2aecabe

4 files changed

Lines changed: 179 additions & 169 deletions

File tree

zjit/src/codegen.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ use crate::stats::{counter_ptr, with_time_stat, trace_compile_phase, Counter, Co
2525
use crate::{asm::CodeBlock, cruby::*, options::debug, virtualmem::CodePtr};
2626
use crate::backend::lir::{self, Assembler, CArgLocation, C_ARG_OPNDS, C_RET_OPND, CFP, EC, NATIVE_BASE_PTR, NATIVE_STACK_PTR, Opnd, SP, SideExit, SideExitRecompile, SideExitTarget, StackMap, StackMapEntry, Target, asm_ccall, asm_comment};
2727
use crate::hir::{self, iseq_to_hir, iseq_to_hir_exception, BlockId, Invariant, RangeType, SideExitReason::{self, *}, SpecialBackrefSymbol, SpecialObjectType};
28-
use crate::hir::{BlockHandler, CCallVariadicData, CCallWithFrameData, Const, FieldName, FrameState, Function, Insn, InsnId, Recompile, SendDirectData, SendFallbackReason, qualified_method_name};
28+
use crate::hir::{BlockHandler, callee_passes_kw_bits_arg, CCallVariadicData, CCallWithFrameData, Const, FieldName, FrameState, Function, Insn, InsnId, Recompile, SendDirectData, SendFallbackReason, qualified_method_name};
2929
use crate::hir_type::{types, Type};
3030
use crate::options::{get_option, InlineDepth, PerfMap, DEFAULT_MAX_VERSIONS};
3131
use crate::cast::IntoUsize;
@@ -2843,7 +2843,11 @@ fn gen_send_iseq_direct(
28432843
// We write this to the local table slot at bits_start so that:
28442844
// 1. The interpreter can read it via checkkeyword if we side-exit
28452845
// 2. The JIT entry can read it from the callee frame slot
2846-
if unsafe { rb_get_iseq_flags_has_kw(iseq) } {
2846+
// A callee whose `kw_bits` slot is followed by a `**rest` local takes the bitmask as an
2847+
// ordinary argument instead, which both writes the right frame slot when the arguments
2848+
// are spilled in local order and lets the JIT entry read it as a parameter. See
2849+
// `callee_passes_kw_bits_arg`.
2850+
if unsafe { rb_get_iseq_flags_has_kw(iseq) } && !callee_passes_kw_bits_arg(iseq) {
28472851
let keyword = unsafe { rb_get_iseq_body_param_keyword(iseq) };
28482852
let bits_start = unsafe { (*keyword).bits_start } as usize;
28492853
let unspecified_bits = VALUE::fixnum_from_usize(kw_bits as usize);

zjit/src/codegen_tests.rs

Lines changed: 65 additions & 134 deletions
Original file line numberDiff line numberDiff line change
@@ -1108,168 +1108,99 @@ fn test_zsuper_to_forwardable_callee() {
11081108
}
11091109

11101110
#[test]
1111-
fn test_inlined_forwarder_positional_args() {
1112-
// The forwarder is inlined into its caller, so the `bar(...)` inside it sees the caller's
1113-
// callinfo at compile time and becomes a direct call to `target`.
1114-
assert_snapshot!(with_inlining(|| assert_inlines("
1115-
def target(a, b) = a - b
1116-
def fwd(...) = target(...)
1117-
def entry = fwd(7, 2)
1118-
200.times { entry }
1119-
entry
1120-
")), @"5");
1121-
}
1122-
1123-
#[test]
1124-
fn test_inlined_forwarder_keyword_args() {
1125-
// `vm_caller_setup_fwd_args` gives the merged callinfo the *caller's* keyword table, so the
1126-
// expanded call has to bind the trailing arguments as keywords, not as positionals.
1127-
assert_snapshot!(with_inlining(|| assert_inlines(r#"
1128-
def target(a, b:, c: 3) = [a, b, c]
1129-
def fwd(...) = target(...)
1130-
def entry = [fwd(1, b: 2), fwd(1, c: 9, b: 2)]
1131-
200.times { entry }
1132-
entry
1133-
"#)), @"[[1, 2, 3], [1, 2, 9]]");
1111+
fn test_kwrest_only_no_caller_keywords() {
1112+
assert_snapshot!(inspect("
1113+
def target(a, **opts) = [a, opts]
1114+
5.times.map { target(1) }.uniq
1115+
"), @"[[1, {}]]");
11341116
}
11351117

11361118
#[test]
1137-
fn test_inlined_forwarder_site_writes_its_own_args() {
1138-
// `bar(x, ...)`: the merged argument list is the site's own arguments followed by the
1139-
// caller's, in that order.
1140-
assert_snapshot!(with_inlining(|| assert_inlines("
1141-
def target(a, b, c) = [a, b, c]
1142-
def fwd(x, ...) = target(x, ...)
1143-
def entry = fwd(1, 2, 3)
1144-
200.times { entry }
1145-
entry
1146-
")), @"[1, 2, 3]");
1119+
fn test_kwrest_only_with_caller_keywords() {
1120+
assert_snapshot!(inspect("
1121+
def target(a, **opts) = [a, opts]
1122+
5.times.map { target(1, x: 2, y: 3) }.uniq
1123+
"), @"[[1, {x: 2, y: 3}]]");
11471124
}
11481125

11491126
#[test]
1150-
fn test_inlined_forwarder_carrying_a_literal_block() {
1151-
// `bh = VM_ENV_BLOCK_HANDLER(GET_LEP())`: the forwarded call gets the forwarder frame's own
1152-
// block handler, which the expanded call reads back out of the frame's EP. Re-deriving the
1153-
// literal block instead would capture the wrong frame, since the block belongs to `entry`.
1154-
assert_snapshot!(with_inlining(|| assert_inlines("
1155-
def target(x) = yield(x)
1156-
def fwd(...) = target(...)
1157-
def entry = fwd(4) { |v| v * 2 }
1158-
200.times { entry }
1159-
entry
1160-
")), @"8");
1127+
fn test_kwrest_with_named_keywords() {
1128+
assert_snapshot!(inspect("
1129+
def target(a, b: 1, **opts) = [a, b, opts]
1130+
5.times.flat_map { [target(1), target(1, b: 2), target(1, z: 3, b: 2)] }.uniq
1131+
"), @"[[1, 1, {}], [1, 2, {}], [1, 2, {z: 3}]]");
11611132
}
11621133

11631134
#[test]
1164-
fn test_inlined_forwarder_block_present_on_some_calls_only() {
1165-
// A `&blk` handed to the forwarder may be a Proc on one call and nothing on the next, so the
1166-
// handler the expanded call passes on is only known at run time. `block_given?` in the target
1167-
// has to see each call for what it was.
1168-
assert_snapshot!(with_inlining(|| assert_inlines_allowing_exits(r#"
1169-
def target(x) = [x, block_given? ? yield(x) : :none]
1170-
def fwd(...) = target(...)
1171-
def entry(i, &b) = fwd(i, &b)
1172-
200.times { |i| i.even? ? entry(i) { |v| v } : entry(i) }
1173-
[entry(1) { |v| v * 2 }, entry(1)]
1174-
"#)), @"[[1, 2], [1, :none]]");
1135+
fn test_kwrest_with_required_keyword_missing() {
1136+
assert_snapshot!(inspect(r#"
1137+
def target(a, b:, **opts) = [a, b, opts]
1138+
5.times.map { (target(1, q: 5) rescue $!.message) }.uniq
1139+
"#), @r#"["missing keyword: :b"]"#);
11751140
}
11761141

11771142
#[test]
1178-
fn test_inlined_forwarder_argument_error() {
1179-
// The argument check belongs to the target, which the inlined forwarder now calls directly.
1180-
assert_snapshot!(with_inlining(|| assert_inlines_allowing_exits(r#"
1181-
def target(a, b) = a + b
1182-
def fwd(...) = target(...)
1183-
def entry = (fwd(1) rescue $!.message)
1184-
200.times { entry }
1185-
entry
1186-
"#)), @r#""wrong number of arguments (given 1, expected 2)""#);
1143+
fn test_kwrest_with_rest_and_optional() {
1144+
assert_snapshot!(inspect("
1145+
def target(a, b = 9, *r, c:, d: 4, **opts) = [a, b, r, c, d, opts]
1146+
5.times.map { target(1, 2, 3, 4, c: 5, e: 6) }.uniq
1147+
"), @"[[1, 2, [3, 4], 5, 4, {e: 6}]]");
11871148
}
11881149

11891150
#[test]
1190-
fn test_inlined_forwarder_side_exit_resumes_the_sendforward() {
1191-
// A guard inside the inlined forwarder exits to the `sendforward` instruction, and the
1192-
// interpreter's `vm_adjust_stack_forwarding` rebuilds the argument list by reading below the
1193-
// frame at `lep - (local_table_size + argc + 2)`. That only works because the inlined frame
1194-
// push copied the arguments into those slots and put the callinfo above them, the way
1195-
// `vm_call_iseq_forwardable` does.
1196-
assert_snapshot!(with_inlining(|| assert_inlines_allowing_exits(r#"
1197-
class A; def m(a, b, c) = [:a, a, b, c]; end
1198-
class B; def m(a, b, c) = [:b, a, b, c]; end
1199-
class Fwd
1200-
def initialize(t) = @t = t
1201-
def m(...) = @t.m(...)
1151+
fn test_send_exit_with_kwrest_callee() {
1152+
// The callee never compiles, so the direct send's function stub spills the caller's
1153+
// arguments into the callee frame in local order and exits to the interpreter with it.
1154+
// The callee's locals run (lead, opt, rest, post, kw..., kw_bits, kwrest), so the
1155+
// `**rest` Hash only lands in its own local if the hidden `kw_bits` slot takes an
1156+
// argument of its own. Without that, the Hash goes into `kw_bits` and the `**rest`
1157+
// local keeps whatever the VM stack already held.
1158+
assert_snapshot!(inspect("
1159+
def target(a, b = 9, *r, c, k: 1, **opts)
1160+
::RubyVM::ZJIT.induce_compile_failure!
1161+
[a, b, r, c, k, opts]
12021162
end
1203-
fa = Fwd.new(A.new)
1204-
fb = Fwd.new(B.new)
1205-
# Warm up on A alone so the expanded call guards on A.
1206-
200.times { fa.m(1, 2, 3) }
1207-
# B fails that guard mid-forwarder.
1208-
[fa.m(1, 2, 3), fb.m(4, 5, 6)]
1209-
"#)), @"[[:a, 1, 2, 3], [:b, 4, 5, 6]]");
1163+
5.times.map { target(1, 2, 3, 4, 5, k: 6, z: 7) }.uniq
1164+
"), @"[[1, 2, [3, 4], 5, 6, {z: 7}]]");
12101165
}
12111166

12121167
#[test]
1213-
fn test_inlined_forwarder_chained_forwarding_falls_back() {
1214-
// The inner target is itself a `def bar(...)`, whose `...` local has to receive a real
1215-
// callinfo. No `rb_callinfo` describes the merged call, so the site keeps its `sendforward`.
1216-
assert_snapshot!(with_inlining(|| assert_inlines("
1217-
def target(a, b:) = [a, b]
1218-
def inner(...) = target(...)
1219-
def outer(...) = inner(...)
1220-
def entry = outer(1, b: 2)
1221-
200.times { entry }
1222-
entry
1223-
")), @"[1, 2]");
1168+
fn test_send_exit_with_kwrest_callee_defaulted_keyword() {
1169+
// As above, but the caller leaves the optional keyword out, so `kw_bits` carries a set
1170+
// bit for the non-constant default. The interpreter has to see that bitmask in the
1171+
// hidden slot and the `**rest` Hash in the local above it.
1172+
assert_snapshot!(inspect("
1173+
def default_k = 11
1174+
def target(a, k: default_k, **opts)
1175+
::RubyVM::ZJIT.induce_compile_failure!
1176+
[a, k, opts]
1177+
end
1178+
5.times.map { target(1, z: 7) }.uniq
1179+
"), @"[[1, 11, {z: 7}]]");
12241180
}
12251181

12261182
#[test]
1227-
fn test_inlined_forwarder_ruby2_keywords() {
1228-
// A `ruby2_keywords` frame splats into the forwarder, which keeps the call site off the
1229-
// direct send entirely; the flagged Hash still has to reach the target as keywords.
1230-
assert_snapshot!(with_inlining(|| assert_inlines_allowing_exits("
1231-
def target(*a, **k) = [a, k]
1232-
def fwd(...) = target(...)
1233-
ruby2_keywords def r2k(*a) = fwd(*a)
1234-
def entry = r2k(1, k: 2)
1235-
200.times { entry }
1236-
entry
1237-
")), @"[[1], {k: 2}]");
1183+
fn test_kwrest_only_kwrest_param() {
1184+
assert_snapshot!(inspect("
1185+
def target(**opts) = opts
1186+
5.times.flat_map { [target, target(k: 1)] }.uniq
1187+
"), @"[{}, {k: 1}]");
12381188
}
12391189

12401190
#[test]
1241-
fn test_inlined_forwarder_super_is_unaffected() {
1242-
// `super` out of a forwardable frame goes through `invokesuperforward`, which
1243-
// `vm_search_super_method` rebuilds the callinfo for at run time. Inlining the frame must
1244-
// not disturb it.
1245-
assert_snapshot!(with_inlining(|| assert_inlines_allowing_exits(r#"
1246-
class Base
1247-
def run(*a, **k) = ["base", a, k]
1248-
end
1249-
class Child < Base
1250-
def run(...) = super
1251-
end
1252-
c = Child.new
1253-
def call_it(c) = c.run(1, k: 2)
1254-
200.times { call_it(c) }
1255-
call_it(c)
1256-
"#)), @r#"["base", [1], {k: 2}]"#);
1191+
fn test_kwrest_anonymous_stays_dynamic() {
1192+
assert_snapshot!(inspect("
1193+
def target(**) = :anon
1194+
5.times.map { target }.uniq
1195+
"), @"[:anon]");
12571196
}
12581197

12591198
#[test]
1260-
fn test_inlined_forwarder_with_extra_locals() {
1261-
// The `...` local is local 0 and the frame extension sits below the whole local table, so a
1262-
// forwarder with locals of its own still finds its arguments where the interpreter left them.
1263-
assert_snapshot!(with_inlining(|| assert_inlines("
1264-
def target(a) = a * 2
1265-
def fwd(...)
1266-
extra = 10
1267-
extra + target(...)
1268-
end
1269-
def entry = fwd(3)
1270-
200.times { entry }
1271-
entry
1272-
")), @"16");
1199+
fn test_kwrest_splat_and_kwrest() {
1200+
assert_snapshot!(inspect("
1201+
def target(*a, **opts) = [a, opts]
1202+
5.times.flat_map { [target, target(1, 2, k: 3)] }.uniq
1203+
"), @"[[[], {}], [[1, 2], {k: 3}]]");
12731204
}
12741205

12751206
#[test]

0 commit comments

Comments
 (0)