Skip to content

Commit 421d05c

Browse files
committed
ZJIT: Back ivar shape-chain misses with a per-name shape table
ZJIT specializes an instance variable access by profiling the receiver's shapes and emitting a chain of shape_id guards, one arm per profiled shape. That covers a site that sees a handful of shapes. It does not cover code like Shopify's Storefront Renderer, where one class has hundreds of live shapes because instances stop at different points in a long chain of conditionally-assigned ivars: the chain cannot be widened far enough (each arm is code, and MAX_IVAR_RESPECIALIZATIONS bounds the recompiles on purpose), so those receivers fall off the end of the chain into a generic rb_ivar_get / rb_vm_getinstancevariable call. On SFR that is 10.8M getivar and 2.4M setivar generic calls per ~350 requests, each one walking the shape tree for an answer that only depends on the receiver's shape id. Put a lookup table between the guard chain and that call. Each ivar *name* accessed by such a site gets one direct-mapped table of 8-byte entries keyed by the raw shape id, holding the byte offset of the ivar within the receiver. Reads probe it inline -- the shape id is already in a register for the guard chain, so it is a multiply, a shift, a mask, two 32-bit loads, two compares and a load -- and produce the value with no call at all, including when the answer is nil because the shape does not have the ivar. Writes have no inline probe (a store also needs a frozen check and a write barrier) but resolve out of the same table. Keying by name, not by site: shape_id -> location of @name does not depend on the site, so sites sharing an ivar warm each other's entries, and the table size multiplies by the number of ivar names accessed polymorphically rather than by the number of compiled sites (and their recompiles). Nothing needs invalidating. Shapes are immutable and the shape tree is append-only, so shape_id -> index is a pure function; and every mutation that could move an object's ivars -- adding one, remove_instance_variable, freeze, object_id, going too-complex, compaction changing embedded capacity or layout -- changes a bit of the object's shape_id, which is stored unmasked as the key. The table holds no VALUEs, so the GC has nothing to mark. Entries are single naturally-aligned words published with one relaxed atomic store, so a concurrent ractor sees an old entry or a new one, never a mix. Measured with benchmark/zjit_ivar_megashape.rb (110-deep ivar chain, 220 shapes per name), instructions retired per access relative to --zjit-disable-ivar-cache, for a cyclic access pattern (the worst case for a direct-mapped table) and a Zipf-skewed one (what applications look like): cyclic skewed attr_reader 0.37 0.30 plain read 0.47 0.36 absent read 0.33 0.41 attr_writer 0.43 0.48 plain write 0.35 0.40 monomorphic 1.00 1.00 Wall clock on the same benchmark moves 220 -> 26 ns/op for an absent read and 55 -> 21 ns/op for an attr_reader. The monomorphic and low- polymorphism paths are untouched by construction: only the codegen of Insn::GetIvar and Insn::SetIvar changed, which a shape-specialized site never emits. HIR and fast-path disassembly for a monomorphic site are identical to before. Memory is 8 bytes times --zjit-ivar-cache-entries (default 512, i.e. 4KiB) per ivar name, reported as mem_ivar_cache_bytes and mem_ivar_cache_count in the mem_* breakdown. Undersizing the table is not a graceful degradation -- a direct-mapped table smaller than the shape working set misses on nearly every access -- so the default is chosen from the measured curve; see DEFAULT_CACHE_ENTRIES. New counters: getivar_cache_hit (served inline, no call), getivar_cache_helper_hit, _fill, _evict, _uncacheable, _immediate, the setivar_cache_* equivalents, and ivar_cache_alloc_count. --zjit-disable-ivar-cache restores the previous code exactly, for A/B. [zjit/min port note] Ported without mem_stats.rs (the memdiet accounting), keeping master's plain-HashMap profile layout. The two emit_ivar_reprofile() call sites are re-added by hand: upstream gates them on ShapeMiss::CallFallback, an enum that comes from excluded work, so here they sit on the two paths that call the generic fallback instead (no-profile under a no-exit policy, and a shape-chain miss), which is the same set of sites for this tree. [reorder port note] Ported ahead of the megamorphic send class table and the ivar-reprofile machinery: send_cache options/counters/state and the IvarReprofile/gen_ivar_reprofile pieces were dropped from conflict hunks; they return with their own commits.
1 parent 0c0b0cc commit 421d05c

8 files changed

Lines changed: 965 additions & 9 deletions

File tree

benchmark/zjit_ivar_megashape.rb

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# Shape-polymorphic instance variable access.
2+
#
3+
# Models the Shopify Storefront Renderer pattern that motivates ZJIT's ivar
4+
# shape table (see zjit/src/ivar_cache.rs): a single class whose instances stop
5+
# at many different points in one long ivar chain, so every ivar site sees
6+
# hundreds of distinct shape ids and no bounded inline guard chain can cover the
7+
# traffic.
8+
#
9+
# Two access distributions are measured, because they stress the table in
10+
# opposite ways:
11+
#
12+
# cyclic every shape is touched exactly once per pass, in a stride order. The
13+
# worst case for a direct-mapped table: any slot holding two live
14+
# shapes misses on every access.
15+
# skewed shape rank r is picked with weight 1/(r+1), which is what a real
16+
# application's shape distribution at one site looks like.
17+
#
18+
# The population is DEPTHS instances at distinct chain depths, doubled by
19+
# freezing half of them (the frozen bit is part of shape_id, so it doubles the
20+
# shape count without adding shape *variations*, which past SHAPE_MAX_VARIATIONS
21+
# would demote the class to hash-backed complex shapes).
22+
#
23+
# ruby --zjit benchmark/zjit_ivar_megashape.rb
24+
# ruby --zjit --zjit-stats benchmark/zjit_ivar_megashape.rb # table counters
25+
# ruby --zjit --zjit-disable-ivar-cache benchmark/zjit_ivar_megashape.rb
26+
#
27+
# Wall and CPU time both swing by 2x on a loaded machine; for A/B work prefer
28+
# `perf stat -e instructions` over two iteration counts and compare the slope.
29+
#
30+
# Env knobs: ITERS, REPS, DEPTHS.
31+
32+
DEPTHS = Integer(ENV.fetch("DEPTHS", 110))
33+
ITERS = Integer(ENV.fetch("ITERS", 2000))
34+
REPS = Integer(ENV.fetch("REPS", 5))
35+
36+
class Mega
37+
body = +"def initialize(depth)\n"
38+
DEPTHS.times do |i|
39+
body << " @i#{i} = #{i}\n"
40+
body << " return if depth == #{i + 1}\n"
41+
end
42+
body << "end\n"
43+
eval(body) # rubocop:disable Security/Eval
44+
45+
attr_reader :i0
46+
attr_writer :i0
47+
48+
def read_first = @i0
49+
def read_absent = @never_assigned
50+
def write_first(v) = (@i0 = v)
51+
end
52+
53+
class Mono
54+
def initialize
55+
@a = 1
56+
@b = 2
57+
end
58+
attr_reader :a
59+
attr_writer :a
60+
def read_a = @a
61+
end
62+
63+
Mega.new(DEPTHS) # prime RCLASS_MAX_IV_COUNT so every instance is embedded
64+
65+
READ_POP = ((1..DEPTHS).map { |d| Mega.new(d) } +
66+
(1..DEPTHS).map { |d| Mega.new(d).freeze })
67+
WRITE_POP = (1..DEPTHS).map { |d| Mega.new(d) }
68+
69+
def cyclic(pop, n) = Array.new(n) { |i| pop[(i * 97) % pop.size] }
70+
71+
def skewed(pop, n)
72+
cdf = []
73+
acc = 0.0
74+
total = (0...pop.size).sum { |r| 1.0 / (r + 1) }
75+
pop.each_index { |r| acc += 1.0 / (r + 1); cdf << acc / total }
76+
rng = Random.new(1234)
77+
Array.new(n) { x = rng.rand; pop[cdf.index { |c| c >= x } || pop.size - 1] }
78+
end
79+
80+
POPS = {
81+
"cyclic" => [cyclic(READ_POP, 2 * DEPTHS).freeze, cyclic(WRITE_POP, 2 * DEPTHS).freeze],
82+
"skewed" => [skewed(READ_POP, 2 * DEPTHS).freeze, skewed(WRITE_POP, 2 * DEPTHS).freeze],
83+
}
84+
MONO = Array.new(2 * DEPTHS) { Mono.new }.freeze
85+
86+
def bench_attr_reader(pop) = pop.each { |o| o.i0 }
87+
def bench_plain_read(pop) = pop.each { |o| o.read_first }
88+
def bench_absent_read(pop) = pop.each { |o| o.read_absent }
89+
def bench_attr_writer(pop) = pop.each { |o| o.i0 = 7 }
90+
def bench_plain_write(pop) = pop.each { |o| o.write_first(9) }
91+
def bench_mono_reader(pop) = pop.each { |o| o.a }
92+
def bench_mono_plain(pop) = pop.each { |o| o.read_a }
93+
def bench_mono_writer(pop) = pop.each { |o| o.a = 3 }
94+
95+
benches = []
96+
POPS.each do |kind, (read_pop, write_pop)|
97+
benches << ["attr_reader/#{kind}", method(:bench_attr_reader), read_pop]
98+
benches << ["plain read/#{kind}", method(:bench_plain_read), read_pop]
99+
benches << ["absent read/#{kind}", method(:bench_absent_read), read_pop]
100+
benches << ["attr_writer/#{kind}", method(:bench_attr_writer), write_pop]
101+
benches << ["plain write/#{kind}", method(:bench_plain_write), write_pop]
102+
end
103+
benches << ["attr_reader/mono", method(:bench_mono_reader), MONO]
104+
benches << ["plain read/mono", method(:bench_mono_plain), MONO]
105+
benches << ["attr_writer/mono", method(:bench_mono_writer), MONO]
106+
107+
# Warm every site up so ZJIT compiles it (and finishes respecializing) first.
108+
benches.each { |_, m, pop| 60.times { m.call(pop) } }
109+
110+
width = benches.map { |name, _, _| name.length }.max
111+
totals = Hash.new(0.0)
112+
benches.each do |name, m, pop|
113+
best = Float::INFINITY
114+
REPS.times do
115+
t0 = Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID)
116+
ITERS.times { m.call(pop) }
117+
t1 = Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID)
118+
best = [best, t1 - t0].min
119+
end
120+
totals[name.split("/").last] += best
121+
puts format("%-#{width}s %8.4f s %7.2f ns/op", name, best, best / (ITERS * pop.size) * 1e9)
122+
end
123+
totals.each { |kind, secs| puts format("%-#{width}s %8.4f s", "TOTAL #{kind}", secs) }

zjit/src/codegen.rs

Lines changed: 141 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1223,7 +1223,7 @@ fn gen_insn(cb: &mut CodeBlock, jit: &mut JITState, asm: &mut Assembler, functio
12231223
let CCallVariadicData { cfunc, recv, name, args, cme, state, block, .. } = &**insn;
12241224
gen_ccall_variadic(jit, asm, function, *cfunc, *name, opnd!(recv), opnds!(args), *cme, *block, &function.frame_state(*state))
12251225
}
1226-
Insn::GetIvar { self_val, id, ic, state } => gen_getivar(asm, opnd!(self_val), *id, *ic, &function.frame_state(*state)),
1226+
&Insn::GetIvar { self_val, id, ic, state } => gen_getivar(jit, asm, opnd!(self_val), function.type_of(self_val), id, ic, &function.frame_state(state)),
12271227
Insn::SetGlobal { id, val, state } => no_output!(gen_setglobal(jit, asm, function, *id, opnd!(val), &function.frame_state(*state))),
12281228
Insn::GetGlobal { id, state } => gen_getglobal(jit, asm, function, *id, &function.frame_state(*state)),
12291229
&Insn::IsBlockParamModified { flags } => gen_is_block_param_modified(asm, opnd!(flags)),
@@ -1668,8 +1668,52 @@ fn gen_ccall_variadic(
16681668
result
16691669
}
16701670

1671-
/// Emit an uncached instance variable lookup
1672-
fn gen_getivar(asm: &mut Assembler, recv: Opnd, id: ID, ic: *const iseq_inline_iv_cache_entry, state: &FrameState) -> Opnd {
1671+
/// Emit an instance variable lookup with no compile-time shape information.
1672+
///
1673+
/// The shape guard chain [`crate::hir::Function::dispatch_ivar`] emits covers
1674+
/// the shapes the site's profile named; this is what runs for every other
1675+
/// receiver. A generic call is fine when that is rare, but on shape-polymorphic
1676+
/// code (hundreds of live shapes for one class) it *is* the hot path, so we put a
1677+
/// per-ivar-name shape table in front of it: see [`gen_ivar_cache_probe`] and
1678+
/// [`crate::ivar_cache`].
1679+
fn gen_getivar(jit: &mut JITState, asm: &mut Assembler, recv: Opnd, recv_type: Type, id: ID, ic: *const iseq_inline_iv_cache_entry, state: &FrameState) -> Opnd {
1680+
if get_option!(disable_ivar_cache) {
1681+
return gen_getivar_generic(asm, recv, id, ic, state);
1682+
}
1683+
1684+
let cache = crate::ivar_cache::ivar_cache_for(id);
1685+
let hir_block_id = asm.current_block().hir_block_id;
1686+
let rpo_idx = asm.current_block().rpo_index;
1687+
let slow_block = asm.new_block(hir_block_id, false, rpo_idx);
1688+
let result_block = asm.new_block(hir_block_id, false, rpo_idx);
1689+
let slow_edge = Target::Block(Box::new(lir::BranchEdge { target: slow_block, args: vec![] }));
1690+
let result_edge = |val: Opnd| Target::Block(Box::new(lir::BranchEdge { target: result_block, args: vec![val] }));
1691+
1692+
let val = gen_ivar_cache_probe(jit, asm, recv, recv_type, id, cache, slow_edge);
1693+
gen_incr_counter(asm, Counter::getivar_cache_hit);
1694+
asm.jmp(result_edge(val));
1695+
1696+
asm.set_current_block(slow_block);
1697+
let label = jit.get_label(asm, slow_block, hir_block_id);
1698+
asm.write_label(label);
1699+
// rb_zjit_getivar_cached only ever raises what rb_ivar_get raises, which is
1700+
// what this site called before, so it needs no more frame setup than the
1701+
// generic call it replaces.
1702+
gen_trace_fallback(asm, "getivar");
1703+
use crate::ivar_cache::rb_zjit_getivar_cached;
1704+
let val = asm_ccall!(asm, rb_zjit_getivar_cached, recv, Opnd::const_ptr(cache as *const u8));
1705+
asm.jmp(result_edge(val));
1706+
1707+
asm.set_current_block(result_block);
1708+
let label = jit.get_label(asm, result_block, hir_block_id);
1709+
asm.write_label(label);
1710+
let param = asm.new_block_param(VALUE_BITS);
1711+
asm.current_block().add_parameter(param);
1712+
param
1713+
}
1714+
1715+
/// The generic instance variable lookup, with no shape table in front of it.
1716+
fn gen_getivar_generic(asm: &mut Assembler, recv: Opnd, id: ID, ic: *const iseq_inline_iv_cache_entry, state: &FrameState) -> Opnd {
16731717
gen_trace_fallback(asm, "getivar");
16741718
if ic.is_null() {
16751719
asm_ccall!(asm, rb_ivar_get, recv, id.0.into())
@@ -1679,17 +1723,105 @@ fn gen_getivar(asm: &mut Assembler, recv: Opnd, id: ID, ic: *const iseq_inline_i
16791723
}
16801724
}
16811725

1682-
/// Emit an uncached instance variable store
1726+
/// Emit the inline half of an ivar shape table lookup: hash the receiver's shape
1727+
/// id into the table, load one entry, and on a hit produce the ivar's value with
1728+
/// no call. Jumps to `miss` for anything else -- an immediate receiver, a shape
1729+
/// the table does not hold, or an entry whose kind is neither
1730+
/// [`crate::ivar_cache::EntryKind::Direct`] nor [`crate::ivar_cache::EntryKind::Nil`].
1731+
///
1732+
/// The encoding is documented on [`crate::ivar_cache::Entry`]; every constant
1733+
/// this reads comes from that module so the two halves cannot drift apart.
1734+
fn gen_ivar_cache_probe(
1735+
jit: &mut JITState,
1736+
asm: &mut Assembler,
1737+
recv: Opnd,
1738+
recv_type: Type,
1739+
id: ID,
1740+
cache: *const crate::ivar_cache::IvarCache,
1741+
miss: Target,
1742+
) -> Opnd {
1743+
use crate::ivar_cache::{IVAR_CACHE_HASH_MULT, IVAR_CACHE_INFO_OFFSET, IVAR_CACHE_KEY_OFFSET, IVAR_CACHE_NIL_BIT, IVAR_CACHE_NIL_SLOT, IVAR_CACHE_NOT_INLINE_MASK, cache_byte_mask, cache_hash_shift};
1744+
1745+
asm_comment!(asm, "ivar shape table probe for :{}", id.contents_lossy());
1746+
let recv = asm.load_mem(recv);
1747+
if !recv_type.is_subtype(types::HeapBasicObject) {
1748+
// Immediates and false have no shape id word to load.
1749+
asm.cmp(recv, Opnd::Value(Qfalse));
1750+
asm.je(jit, miss.clone());
1751+
asm.test(recv, Opnd::UImm(RUBY_IMMEDIATE_MASK as u64));
1752+
asm.jnz(jit, miss.clone());
1753+
}
1754+
1755+
let shape_offset = unsafe { rb_shape_id_offset() };
1756+
// Two loads of the same word, because `Assembler::mul` is destructive: the
1757+
// x86 lowering emits `imul left, right` and copies the result out afterwards,
1758+
// so whatever register held the multiplicand is clobbered. (Same for lshift,
1759+
// rshift, urshift and not; add/and/or/xor copy into the output first and are
1760+
// safe.) The second load is what the key comparison needs, and it is a
1761+
// guaranteed L1 hit on a line the first load just brought in.
1762+
let shape_to_hash = asm.load(Opnd::mem(SHAPE_ID_NUM_BITS as u8, recv, shape_offset));
1763+
let shape = asm.load(Opnd::mem(SHAPE_ID_NUM_BITS as u8, recv, shape_offset));
1764+
1765+
// Fibonacci hash, taking the byte offset out of the product's high bits;
1766+
// this has to agree with `crate::ivar_cache::slot_of` or the helper will fill
1767+
// slots the probe does not read. Widening the loaded shape id to 64 bits
1768+
// relies on a 32-bit load zero-extending its destination register, which both
1769+
// backends do; if one ever did not, the only consequence would be probing a
1770+
// slot the helper does not fill -- a miss, not a wrong answer.
1771+
let hash = asm.mul(shape_to_hash.with_num_bits(64), Opnd::UImm(IVAR_CACHE_HASH_MULT));
1772+
let shifted = asm.urshift(hash, Opnd::UImm(cache_hash_shift()));
1773+
let slot = asm.and(shifted, Opnd::UImm(cache_byte_mask()));
1774+
let table = unsafe { (*cache).table_ptr() };
1775+
let slot_ptr = asm.add(slot, Opnd::const_ptr(table));
1776+
1777+
asm_comment!(asm, "check the entry's shape id");
1778+
let key = asm.load(Opnd::mem(32, slot_ptr, IVAR_CACHE_KEY_OFFSET));
1779+
asm.cmp(key, shape);
1780+
asm.jne(jit, miss.clone());
1781+
1782+
// The entry's high half is the byte offset plus the kind. Direct and Nil are
1783+
// servable here; everything else goes to the helper.
1784+
let info = asm.load(Opnd::mem(32, slot_ptr, IVAR_CACHE_INFO_OFFSET));
1785+
asm.test(info, Opnd::UImm(IVAR_CACHE_NOT_INLINE_MASK));
1786+
asm.jnz(jit, miss);
1787+
1788+
asm_comment!(asm, "load the ivar at the cached offset, or nil if absent");
1789+
let offset = asm.and(info.with_num_bits(64), Opnd::UImm(u16::MAX as u64));
1790+
let ivar_ptr = asm.add(offset, recv);
1791+
// A Nil entry means the shape has no such ivar. Rather than branch, read a
1792+
// static word that holds Qnil: this keeps the absent case -- the most
1793+
// expensive one to resolve generically, because the search has to fail --
1794+
// on the inline path for two extra instructions.
1795+
asm.test(info, Opnd::UImm(IVAR_CACHE_NIL_BIT));
1796+
let nil_slot = Opnd::const_ptr(std::ptr::addr_of!(IVAR_CACHE_NIL_SLOT) as *const u8);
1797+
let load_from = asm.csel_nz(nil_slot, ivar_ptr);
1798+
asm.load(Opnd::mem(VALUE_BITS, load_from, 0))
1799+
}
1800+
1801+
/// Emit an instance variable store with no compile-time shape information.
1802+
///
1803+
/// The counterpart of [`gen_getivar`]: the shape guard chain covers what the
1804+
/// profile named, and this runs for everything else. There is no inline probe on
1805+
/// the write side -- a store also needs a frozen check and a write barrier, which
1806+
/// is a lot of code to inline for a quarter of the traffic reads see -- but the
1807+
/// call goes to a helper that resolves the location out of the same per-name
1808+
/// shape table instead of walking the shape tree. See [`crate::ivar_cache`].
16831809
fn gen_setivar(jit: &mut JITState, asm: &mut Assembler, function: &Function, recv: Opnd, id: ID, ic: *const iseq_inline_iv_cache_entry, val: Opnd, state: &FrameState) {
16841810
gen_trace_fallback(asm, "setivar");
16851811
// Setting an ivar can raise FrozenError, so we need proper frame state for exception handling.
16861812
gen_prepare_non_leaf_call(jit, asm, function, state);
1687-
if ic.is_null() {
1688-
asm_ccall!(asm, rb_ivar_set, recv, id.0.into(), val);
1689-
} else {
1690-
let iseq = Opnd::Value(state.iseq.into());
1691-
asm_ccall!(asm, rb_vm_setinstancevariable, iseq, recv, id.0.into(), val, Opnd::const_ptr(ic));
1813+
if get_option!(disable_ivar_cache) {
1814+
if ic.is_null() {
1815+
asm_ccall!(asm, rb_ivar_set, recv, id.0.into(), val);
1816+
} else {
1817+
let iseq = Opnd::Value(state.iseq.into());
1818+
asm_ccall!(asm, rb_vm_setinstancevariable, iseq, recv, id.0.into(), val, Opnd::const_ptr(ic));
1819+
}
1820+
return;
16921821
}
1822+
let cache = crate::ivar_cache::ivar_cache_for(id);
1823+
use crate::ivar_cache::rb_zjit_setivar_cached;
1824+
asm_ccall!(asm, rb_zjit_setivar_cached, recv, val, Opnd::const_ptr(cache as *const u8));
16931825
}
16941826

16951827
fn gen_getclassvar(jit: &mut JITState, asm: &mut Assembler, function: &Function, id: ID, ic: *const iseq_inline_cvar_cache_entry, state: &FrameState) -> Opnd {

0 commit comments

Comments
 (0)