Skip to content

Commit a95d616

Browse files
committed
ZJIT: Compile opt_case_dispatch instead of the === chain
`opt_case_dispatch` looks a `case` value up in a compile-time hash and jumps straight to the matching `when` body. ZJIT popped the key and fell through to the `===` chain the compiler emits after it, which costs one `Integer#===` send per `when` clause tested. Worse, the interpreter never executes that chain -- it jumps out of `opt_case_dispatch` on a hit -- so none of those sends are ever profiled. Every one of them compiled to an unprofiled, un-inlined `CCallWithFrame` to `Integer#===`. On the rubyboy benchmark that was 840M cfunc calls, 90% of all calls to non-inlined C methods and 63% of all calls to C from JIT code, for a `case addr >> 12` bus decoder and a ~500-way CPU opcode dispatch. Compile the lookup instead. When every key in the dispatch hash is a Fixnum, emit a binary search over the sorted keys that branches directly to each `when` body, guarded by a `BOP_EQQ`/Integer patch point (the chain calls `Integer#===`, so the lookup only agrees with it while that is the stock implementation). Keys that are not Fixnums take the `===` chain as before, so behavior is unchanged for them, and hashes with non-Fixnum keys are left alone entirely. Also annotate `Integer#===`, which is `rb_int_equal` just like `Integer#==`, so the `===` chains that remain inline their comparisons. A 256-way `case` over integer literals goes from 1816ms to 150ms (YJIT: 308ms). On rubyboy, calls to C from JIT code drop from 1.32B to 909M and the total executed instruction count drops 27%.
1 parent c6e9858 commit a95d616

5 files changed

Lines changed: 184 additions & 4 deletions

File tree

zjit.c

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,39 @@ rb_zjit_iseq_insn_set(const rb_iseq_t *iseq, unsigned int insn_idx, enum ruby_vm
147147
iseq->body->iseq_encoded[insn_idx] = (VALUE)insn_table[bare_insn];
148148
}
149149

150+
struct zjit_cdhash_entries {
151+
// Interleaved (key, offset) pairs, both as C longs
152+
long *buf;
153+
long capacity;
154+
long size;
155+
};
156+
157+
static int
158+
zjit_cdhash_fixnum_entries_i(st_data_t key, st_data_t val, st_data_t data)
159+
{
160+
struct zjit_cdhash_entries *entries = (struct zjit_cdhash_entries *)data;
161+
if (!FIXNUM_P((VALUE)key) || entries->size >= entries->capacity) {
162+
entries->size = -1;
163+
return ST_STOP;
164+
}
165+
entries->buf[entries->size * 2] = FIX2LONG((VALUE)key);
166+
// Values are raw jump offsets, not Ruby objects. See cdhash_set_label_replace_i().
167+
entries->buf[entries->size * 2 + 1] = (long)val;
168+
entries->size++;
169+
return ST_CONTINUE;
170+
}
171+
172+
// Write the (key, jump offset) pairs of an `opt_case_dispatch` hash into `buf` as
173+
// interleaved C longs. Returns the number of pairs written, or -1 if any key is not
174+
// a Fixnum or if there is not enough room for all of them in `buf`.
175+
long
176+
rb_zjit_cdhash_fixnum_entries(VALUE cdhash, long *buf, long capacity)
177+
{
178+
struct zjit_cdhash_entries entries = { .buf = buf, .capacity = capacity, .size = 0 };
179+
st_foreach(rb_imemo_cdhash_tbl(cdhash), zjit_cdhash_fixnum_entries_i, (st_data_t)&entries);
180+
return entries.size;
181+
}
182+
150183
// Get profiling information for ISEQ
151184
void *
152185
rb_iseq_get_zjit_payload(const rb_iseq_t *iseq)

zjit/src/codegen_tests.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6241,6 +6241,48 @@ fn test_opt_case_dispatch() {
62416241
assert_snapshot!(assert_compiles("[test(:foo), test(1)]"), @"[true, false]");
62426242
}
62436243

6244+
#[test]
6245+
fn test_opt_case_dispatch_fixnum() {
6246+
eval("
6247+
def test(x)
6248+
case x
6249+
when -3 then :a
6250+
when 0, 1 then :b
6251+
when 5 then :c
6252+
else :d
6253+
end
6254+
end
6255+
test(0)
6256+
");
6257+
assert_contains_opcode("test", YARVINSN_opt_case_dispatch);
6258+
assert_snapshot!(
6259+
assert_compiles("[-4, -3, 0, 1, 2, 5, 1.0, :x, nil].map { |x| test(x) }"),
6260+
@"[:d, :a, :b, :b, :d, :c, :b, :d, :d]"
6261+
);
6262+
}
6263+
6264+
#[test]
6265+
fn test_opt_case_dispatch_fixnum_redefined() {
6266+
eval("
6267+
def test(x)
6268+
case x
6269+
when 0 then :a
6270+
when 1 then :b
6271+
else :c
6272+
end
6273+
end
6274+
test(0)
6275+
");
6276+
assert_contains_opcode("test", YARVINSN_opt_case_dispatch);
6277+
assert_snapshot!(assert_compiles_allowing_exits("
6278+
[test(0), test(1), test(2)].tap {
6279+
class Integer
6280+
def ===(other) = true
6281+
end
6282+
} + [test(0), test(1), test(2)] + 100.times.map { test(2) }.uniq
6283+
"), @"[:a, :b, :c, :a, :a, :a, :a]");
6284+
}
6285+
62446286
#[test]
62456287
fn test_checkmatch_case() {
62466288
eval(r#"

zjit/src/cruby.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ unsafe extern "C" {
127127
pub fn rb_float_new(d: f64) -> VALUE;
128128

129129
pub fn rb_hash_empty_p(hash: VALUE) -> VALUE;
130+
pub fn rb_zjit_cdhash_fixnum_entries(cdhash: VALUE, buf: *mut c_long, capacity: c_long) -> c_long;
130131
pub fn rb_ary_new_from_args(n: c_long, ...) -> VALUE;
131132
pub fn rb_str_setbyte(str: VALUE, index: VALUE, value: VALUE) -> VALUE;
132133
pub fn rb_str_getbyte(str: VALUE, index: VALUE) -> VALUE;

zjit/src/cruby_methods.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,10 @@ pub fn init() -> Annotations {
252252
annotate!(rb_cInteger, "succ", inline_integer_succ);
253253
annotate!(rb_cInteger, "^", inline_integer_xor);
254254
annotate!(rb_cInteger, "==", inline_integer_eq);
255+
// Integer#=== is rb_int_equal, the same C function as Integer#==. `case`/`when`
256+
// over integer literals compiles to a chain of `===` sends when opt_case_dispatch
257+
// misses, so inlining this avoids a cfunc call per `when` clause tested.
258+
annotate!(rb_cInteger, "===", inline_integer_eq);
255259
annotate!(rb_cInteger, "+", inline_integer_plus);
256260
annotate!(rb_cInteger, "-", inline_integer_minus);
257261
annotate!(rb_cInteger, "*", inline_integer_mult);

zjit/src/hir.rs

Lines changed: 104 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7922,6 +7922,29 @@ struct BytecodeInfo {
79227922
jump_targets: Vec<u32>,
79237923
}
79247924

7925+
/// The largest `opt_case_dispatch` hash we are willing to turn into an inline
7926+
/// binary search. Bigger `case`/`when` statements keep the `===` chain.
7927+
const MAX_CASE_DISPATCH_ENTRIES: usize = 512;
7928+
7929+
/// Read the `(key, jump offset)` pairs out of an `opt_case_dispatch` hash, sorted by key.
7930+
/// Returns None unless every key is a Fixnum, which is what lets us compile the dispatch
7931+
/// as an integer comparison tree guarded by a single `Integer#===` redefinition check.
7932+
fn cdhash_fixnum_entries(cdhash: VALUE) -> Option<Vec<(i64, i64)>> {
7933+
let mut buf = vec![0 as std::os::raw::c_long; MAX_CASE_DISPATCH_ENTRIES * 2];
7934+
let size = unsafe {
7935+
rb_zjit_cdhash_fixnum_entries(cdhash, buf.as_mut_ptr(), MAX_CASE_DISPATCH_ENTRIES as std::os::raw::c_long)
7936+
};
7937+
if size <= 0 {
7938+
return None;
7939+
}
7940+
let mut entries: Vec<(i64, i64)> = buf[..(size as usize) * 2]
7941+
.chunks_exact(2)
7942+
.map(|pair| (pair[0] as i64, pair[1] as i64))
7943+
.collect();
7944+
entries.sort_unstable_by_key(|&(key, _)| key);
7945+
Some(entries)
7946+
}
7947+
79257948
fn compute_bytecode_info(iseq: *const rb_iseq_t, opt_table: &[u32]) -> BytecodeInfo {
79267949
let iseq_size = unsafe { get_iseq_encoded_size(iseq) };
79277950
let mut insn_idx = 0;
@@ -7952,6 +7975,16 @@ fn compute_bytecode_info(iseq: *const rb_iseq_t, opt_table: &[u32]) -> BytecodeI
79527975
let offset = get_arg(pc, 1).as_i64();
79537976
jump_targets.insert(insn_idx_at_offset(insn_idx, offset));
79547977
}
7978+
YARVINSN_opt_case_dispatch => {
7979+
// The `when` bodies are already jump targets of the `===` chain that
7980+
// follows, but the else offset is only reachable by fallthrough there.
7981+
if let Some(entries) = cdhash_fixnum_entries(get_arg(pc, 0)) {
7982+
for (_, offset) in entries {
7983+
jump_targets.insert(insn_idx_at_offset(insn_idx, offset));
7984+
}
7985+
jump_targets.insert(insn_idx_at_offset(insn_idx, get_arg(pc, 1).as_i64()));
7986+
}
7987+
}
79557988
YARVINSN_leave | YARVINSN_opt_invokebuiltin_delegate_leave => {
79567989
if insn_idx < iseq_size {
79577990
jump_targets.insert(insn_idx);
@@ -8930,10 +8963,77 @@ fn add_iseq_to_hir(
89308963
queue.push_back((state.clone(), target, target_idx, local_inval));
89318964
}
89328965
YARVINSN_opt_case_dispatch => {
8933-
// TODO: Some keys are visible at compile time, so in the future we can
8934-
// compile jump targets for certain cases
8935-
// Pop the key from the stack and fallback to the === branches for now
8936-
state.stack_pop()?;
8966+
let key = state.stack_pop()?;
8967+
// The interpreter jumps straight out of `opt_case_dispatch` on a hit, so
8968+
// the `===` chain that follows is dead code there and never gets profiled.
8969+
// Compiling the chain therefore means an unprofiled `Integer#===` cfunc
8970+
// call per `when` clause tested. Compile the hash lookup instead, as a
8971+
// binary search over the (Fixnum) keys.
8972+
// The chain calls `Integer#===` on each `when` literal, so the lookup only
8973+
// agrees with it while that stays the stock implementation.
8974+
let unredefined = unsafe { rb_BASIC_OP_UNREDEFINED_P(BOP_EQQ, INTEGER_REDEFINED_OP_FLAG) };
8975+
let Some(entries) = unredefined.then(|| cdhash_fixnum_entries(get_arg(pc, 0))).flatten() else {
8976+
// Fall through to the `===` chain.
8977+
continue;
8978+
};
8979+
fun.push_insn(block, Insn::PatchPoint {
8980+
invariant: Invariant::BOPRedefined { klass: INTEGER_REDEFINED_OP_FLAG, bop: BOP_EQQ },
8981+
state: exit_id,
8982+
});
8983+
// Only Fixnum keys can match an all-Fixnum dispatch hash. Anything else
8984+
// falls through to the `===` chain, which handles every type correctly.
8985+
let is_fixnum = fun.push_insn(block, Insn::HasType { val: key, expected: types::Fixnum });
8986+
let chain_block = fun.new_block(insn_idx);
8987+
let dispatch_block = fun.new_block(insn_idx);
8988+
fun.push_insn(block, Insn::CondBranch {
8989+
val: is_fixnum,
8990+
if_true: BranchEdge { target: dispatch_block, args: vec![] },
8991+
if_false: BranchEdge { target: chain_block, args: vec![] },
8992+
});
8993+
let key_fixnum = fun.push_insn(dispatch_block, Insn::RefineType { val: key, new_type: types::Fixnum });
8994+
let mut state = state.clone();
8995+
state.replace(key, key_fixnum);
8996+
let else_idx = insn_idx_at_offset(insn_idx, get_arg(pc, 1).as_i64());
8997+
let else_block = insn_idx_to_block[&else_idx];
8998+
// Emit a comparison tree over the sorted keys. Each leaf range is scanned
8999+
// linearly; anything bigger splits on a pivot key.
9000+
let mut work = vec![(0usize, entries.len(), dispatch_block)];
9001+
while let Some((lo, hi, mut cur)) = work.pop() {
9002+
if hi - lo > 3 {
9003+
let mid = lo + (hi - lo) / 2;
9004+
let pivot = fun.push_insn(cur, Insn::Const { val: Const::Value(VALUE::fixnum_from_isize(entries[mid].0 as isize)) });
9005+
let less = fun.push_insn(cur, Insn::FixnumLt { left: key_fixnum, right: pivot });
9006+
let less_c = fun.push_insn(cur, Insn::Test { val: less });
9007+
let lo_block = fun.new_block(insn_idx);
9008+
let hi_block = fun.new_block(insn_idx);
9009+
fun.push_insn(cur, Insn::CondBranch {
9010+
val: less_c,
9011+
if_true: BranchEdge { target: lo_block, args: vec![] },
9012+
if_false: BranchEdge { target: hi_block, args: vec![] },
9013+
});
9014+
work.push((lo, mid, lo_block));
9015+
work.push((mid, hi, hi_block));
9016+
continue;
9017+
}
9018+
for &(key_value, offset) in &entries[lo..hi] {
9019+
let target_idx = insn_idx_at_offset(insn_idx, offset);
9020+
let target = insn_idx_to_block[&target_idx];
9021+
let expected = fun.push_insn(cur, Insn::Const { val: Const::Value(VALUE::fixnum_from_isize(key_value as isize)) });
9022+
let matches = fun.push_insn(cur, Insn::IsBitEqual { left: key_fixnum, right: expected });
9023+
let next = fun.new_block(insn_idx);
9024+
fun.push_insn(cur, Insn::CondBranch {
9025+
val: matches,
9026+
if_true: BranchEdge { target, args: state.as_args(self_param) },
9027+
if_false: BranchEdge { target: next, args: vec![] },
9028+
});
9029+
queue.push_back((state.clone(), target, target_idx, local_inval));
9030+
cur = next;
9031+
}
9032+
fun.push_insn(cur, Insn::Jump(BranchEdge { target: else_block, args: state.as_args(self_param) }));
9033+
queue.push_back((state.clone(), else_block, else_idx, local_inval));
9034+
}
9035+
// Keep compiling the `===` chain for non-Fixnum keys.
9036+
block = chain_block;
89379037
}
89389038
YARVINSN_opt_new => {
89399039
let cd: *const rb_call_data = get_arg(pc, 0).as_ptr();

0 commit comments

Comments
 (0)