Skip to content

Commit 3de96d3

Browse files
committed
ZJIT: Weigh an ivar respecialization by the traffic it would take off the fallback
A frozen ivar dispatch samples what its fallback path is handed and earns an extra compiled version once a 64-sample window names a shape the dispatch has no arm for. One missing shape is weak evidence. At a site that is genuinely polymorphic the fallback sees a long tail of receivers, so a window nearly always contains something new, and the arm the recompile adds covers a few percent of the traffic while the version and the code for the whole dispatch are paid in full. On railsbench that is what happens: the mechanism fires 119 times and dynamic_getivar_count does not move. Weigh the vote by the share of the window the shapes a recompile would specialize account for *together*. A per-shape threshold is the wrong shape of test -- it was tried at 25% and cost rubyboy its win, because its windows are split across two or three shapes that are individually small and collectively everything. The share is read off the bucket indices: ProfileEntry now remembers how many shapes the running dispatch was compiled from, and `Distribution::observe_stable` records a shape without counting or reordering, so any index at or past that mark is a shape the running code has no arm for. Shapes the distribution has no room left for, and too-complex shapes the dispatch would filter back out, are counted as samples but not as fixable ones: they are exactly the evidence that a recompile will not help. The first of those was also a plain bug -- a full distribution silently dropped the shape on the floor and granted a version for an arm the recompile could not add. Declining a window has a cost the old code never paid, because it used to spend its two-version budget almost immediately and the next compile dropped the sampling call. A site that keeps declining keeps sampling, so railsbench closed 31,407 windows -- 2M calls on an otherwise exit-free path. Give each compiled version four windows to spend on evidence that does not pan out, checked with one load off the version the call already has in hand, and leave the sampling out of anything the ISEQ compiles after that. railsbench: ivar_respecialize_count 119 -> 2 with dynamic_getivar_count unchanged (3.739M), so the removed respecializations bought nothing. Against the same tree they take compile_time from 5,394ms to 3,945ms (-27%), code_region_bytes from 13.24MB to 12.53MB (-5%), and compiled_iseq_count from 3,473 to 3,311. Declined windows 31,407 -> 222 across 50 versions that gave up. rubyboy is unchanged in every respect: the same 10 respecializations, the same 23,281,169 dynamic_getivar_count, the same 4,567,040 code_region_bytes, and one declined window in the whole program.
1 parent 75fd147 commit 3de96d3

6 files changed

Lines changed: 200 additions & 25 deletions

File tree

zjit.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,8 @@ def stats_string
147147
:compiled_side_exit_count,
148148
:failed_iseq_count,
149149
:ivar_respecialize_count,
150+
:ivar_respecialize_declined_count,
151+
:ivar_respecialize_giveup_count,
150152

151153
:compile_time_ns,
152154
:compile_side_exit_time_ns,

zjit/src/distribution.rs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,29 @@ impl<T: Copy + PartialEq + Default, const N: usize> Distribution<T, N> {
4444
}
4545
}
4646

47+
/// Look `item` up and, if `matches` finds no bucket holding it and a bucket is free, record
48+
/// it there. Unlike [`Self::observe`] this does not count the sighting and does not reorder
49+
/// the buckets, so the index it reports stays valid for as long as the distribution is only
50+
/// updated this way: a caller that remembers how many buckets were occupied at some earlier
51+
/// point can tell from the index alone whether an item was already known then.
52+
///
53+
/// New buckets always start at a count of 1, which cannot exceed `counts[0]`, so this
54+
/// preserves "`buckets[0]` is the most common item" without bubbling.
55+
pub fn observe_stable(&mut self, item: T, matches: impl Fn(T, T) -> bool) -> StableBucket {
56+
for (index, (bucket, count)) in self.buckets.iter_mut().zip(self.counts.iter_mut()).enumerate() {
57+
if *count == 0 {
58+
*bucket = item;
59+
*count = 1;
60+
return StableBucket::Inserted(index);
61+
}
62+
if matches(*bucket, item) {
63+
return StableBucket::Existing(index);
64+
}
65+
}
66+
self.other = self.other.saturating_add(1);
67+
StableBucket::Full
68+
}
69+
4770
pub fn each_item(&self) -> impl Iterator<Item = T> + '_ {
4871
self.buckets.iter().zip(self.counts.iter())
4972
.filter_map(|(&bucket, &count)| if count > 0 { Some(bucket) } else { None })
@@ -55,6 +78,17 @@ impl<T: Copy + PartialEq + Default, const N: usize> Distribution<T, N> {
5578
}
5679
}
5780

81+
/// Where an item sits in a [`Distribution`] after [`Distribution::observe_stable`].
82+
#[derive(PartialEq, Eq, Debug, Clone, Copy)]
83+
pub enum StableBucket {
84+
/// The item already had this bucket.
85+
Existing(usize),
86+
/// The item was recorded in this bucket, which was empty until now.
87+
Inserted(usize),
88+
/// Every bucket belongs to some other item; this one was only counted as `other`.
89+
Full,
90+
}
91+
5892
#[derive(PartialEq, Debug, Clone, Copy)]
5993
enum DistributionKind {
6094
/// No types seen
@@ -200,6 +234,54 @@ mod distribution_tests {
200234
assert_eq!(dist.other, 1);
201235
}
202236

237+
#[test]
238+
fn observe_stable_reports_new_and_known_buckets() {
239+
let mut dist = Distribution::<usize, 4>::new();
240+
dist.observe(10);
241+
// A bucket that already exists is reported without being counted again.
242+
assert_eq!(dist.observe_stable(10, |a, b| a == b), StableBucket::Existing(0));
243+
assert_eq!(dist.counts[0], 1);
244+
// New items land past the buckets that were occupied, which is how a caller tells
245+
// them apart from the ones the compiled code already knows about.
246+
assert_eq!(dist.observe_stable(11, |a, b| a == b), StableBucket::Inserted(1));
247+
assert_eq!(dist.observe_stable(11, |a, b| a == b), StableBucket::Existing(1));
248+
assert_eq!(dist.observe_stable(12, |a, b| a == b), StableBucket::Inserted(2));
249+
}
250+
251+
#[test]
252+
fn observe_stable_does_not_reorder_buckets() {
253+
let mut dist = Distribution::<usize, 4>::new();
254+
dist.observe(10);
255+
for _ in 0..10 {
256+
dist.observe_stable(11, |a, b| a == b);
257+
}
258+
// `observe` would have bubbled 11 to the front; a stable observation may not, or the
259+
// index it reported earlier would now name a different item.
260+
assert_eq!(dist.buckets[0], 10);
261+
assert_eq!(dist.buckets[1], 11);
262+
// Counts stay ordered, so DistributionSummary's invariant still holds.
263+
assert!(dist.counts[0] >= dist.counts[1]);
264+
}
265+
266+
#[test]
267+
fn observe_stable_reports_full_without_recording() {
268+
let mut dist = Distribution::<usize, 2>::new();
269+
assert_eq!(dist.observe_stable(10, |a, b| a == b), StableBucket::Inserted(0));
270+
assert_eq!(dist.observe_stable(11, |a, b| a == b), StableBucket::Inserted(1));
271+
assert_eq!(dist.observe_stable(12, |a, b| a == b), StableBucket::Full);
272+
assert_eq!(dist.other, 1);
273+
assert_eq!(dist.buckets, [10, 11]);
274+
}
275+
276+
#[test]
277+
fn observe_stable_matches_with_the_given_predicate() {
278+
let mut dist = Distribution::<(usize, usize), 4>::new();
279+
dist.observe_stable((1, 100), |a, b| a.0 == b.0);
280+
// Equal under the predicate, different as a whole: still the same bucket.
281+
assert_eq!(dist.observe_stable((1, 999), |a, b| a.0 == b.0), StableBucket::Existing(0));
282+
assert_eq!(dist.buckets[0], (1, 100));
283+
}
284+
203285
#[test]
204286
fn empty_distribution_returns_empty_summary() {
205287
let dist = Distribution::<usize, 4>::new();

zjit/src/hir.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8453,7 +8453,9 @@ impl Function {
84538453
return;
84548454
}
84558455
let payload = get_or_create_iseq_payload(self.iseq);
8456-
if payload.ivar_respecializations >= crate::payload::MAX_IVAR_RESPECIALIZATIONS {
8456+
if payload.ivar_respecializations >= crate::payload::MAX_IVAR_RESPECIALIZATIONS
8457+
|| payload.ivar_reprofile_giveup
8458+
{
84578459
return;
84588460
}
84598461
self.push_insn(block, Insn::IvarReprofile { self_val: self_param, state });

zjit/src/payload.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ pub struct IseqPayload {
3838
/// [`crate::profile::rb_zjit_ivar_reprofile`] grants these, and only against evidence
3939
/// from the fallback path. Capped at [`MAX_IVAR_RESPECIALIZATIONS`].
4040
pub ivar_respecializations: u8,
41+
/// Whether an ivar fallback in this ISEQ has spent a compiled version's worth of
42+
/// re-profiling windows without earning a recompile. Sampling costs a non-leaf call on the
43+
/// fallback path, so once the evidence says a recompile would not help, later compiles of
44+
/// this ISEQ leave the sampling out.
45+
pub ivar_reprofile_giveup: bool,
4146
}
4247

4348
/// The interpreter state observed at one exception-handler entry.
@@ -73,6 +78,7 @@ impl IseqPayload {
7378
self_is_heap_object: false,
7479
num_exits_until_invalidate: get_option!(num_exits_until_invalidate),
7580
ivar_respecializations: 0,
81+
ivar_reprofile_giveup: false,
7682
}
7783
}
7884

@@ -120,8 +126,20 @@ pub struct IseqVersion {
120126

121127
/// JIT-to-JIT calls to the ISEQ. The IseqPayload's ISEQ is the callee of it.
122128
pub incoming: Vec<IseqCallRef>,
129+
130+
/// Re-profiling windows this version's ivar fallback paths may still close without earning a
131+
/// recompile. See [`crate::profile::rb_zjit_ivar_reprofile`]: sampling is a C call on a path
132+
/// that is otherwise exit-free, so a version whose fallbacks keep failing to make the case
133+
/// for a recompile stops paying for the evidence.
134+
pub ivar_reprofile_windows: u8,
123135
}
124136

137+
/// How many windows an ivar fallback may close without earning a recompile before the version
138+
/// stops sampling. A fallback that has handed the same unspecializable mix of shapes to this
139+
/// many windows in a row is not about to change its mind, and every sample after that is a call
140+
/// on a hot path buying nothing.
141+
pub const MAX_IVAR_REPROFILE_WINDOWS: u8 = 4;
142+
125143
/// We use a raw pointer instead of Rc to save space for refcount
126144
pub type IseqVersionRef = NonNull<IseqVersion>;
127145

@@ -139,6 +157,7 @@ impl IseqVersion {
139157
gc_offsets: vec![],
140158
outgoing: vec![],
141159
incoming: vec![],
160+
ivar_reprofile_windows: MAX_IVAR_REPROFILE_WINDOWS,
142161
};
143162
let version_ptr = Box::into_raw(Box::new(version));
144163
NonNull::new(version_ptr).expect("no null from Box")

zjit/src/profile.rs

Lines changed: 92 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
use std::collections::HashMap;
77
use crate::{cruby::*, payload::get_or_create_iseq_payload, options::{get_option, NumProfiles}};
8-
use crate::distribution::{Distribution, DistributionSummary};
8+
use crate::distribution::{Distribution, DistributionSummary, StableBucket};
99
use crate::stats::Counter::profile_time_ns;
1010
use crate::stats::with_time_stat;
1111

@@ -216,19 +216,32 @@ fn profile_self(profiler: &mut Profiler, profile: &mut IseqProfile) {
216216
/// unusual receiver does not spend a version.
217217
const IVAR_REPROFILE_WINDOW: u32 = 64;
218218

219+
/// Share of a window that the shapes a recompile would add an arm for must account for,
220+
/// in percent, before the recompile is worth a version.
221+
///
222+
/// A single missing shape is not evidence that a recompile helps: at a site that is genuinely
223+
/// polymorphic the fallback sees a long tail of shapes, and an arm for one of them removes a
224+
/// few percent of the calls while costing a version and the code for every other arm. What
225+
/// matters is the share the arms a recompile would add cover *together*, which is why this is
226+
/// not a per-shape threshold -- a window split evenly between three missing shapes is a good
227+
/// bet, and a window with one missing shape in it is not.
228+
const IVAR_REPROFILE_MIN_SHARE_PERCENT: u32 = 50;
229+
219230
/// Result of [`IseqProfile::observe_ivar_fallback`].
220231
#[derive(PartialEq, Eq, Debug)]
221232
enum IvarReprofiled {
222-
/// Recorded. Nothing to do.
233+
/// Recorded, mid-window. Nothing to do.
223234
Sampled,
224-
/// The window closed on a shape the compiled dispatch does not have an arm for.
225-
/// The shape is now in the instruction's profile; recompile to pick it up.
235+
/// The window closed without making the case for a recompile.
236+
Declined,
237+
/// The window closed with most of its samples on shapes the compiled dispatch has no arm
238+
/// for. They are now in the instruction's profile; recompile to pick them up.
226239
Recompile,
227240
}
228241

229242
impl IseqProfile {
230243
/// Record the shape of a receiver that reached an ivar site's fallback path, and report
231-
/// whether a recompile would now give that receiver an arm of its own.
244+
/// whether a recompile would now take most of that traffic off the fallback.
232245
///
233246
/// The window is kept in the instruction's own profile entry, so a site that falls back
234247
/// rarely costs one distribution slot and nothing else.
@@ -237,27 +250,58 @@ impl IseqProfile {
237250
if entry.opnd_types.is_empty() {
238251
entry.opnd_types.resize(1, TypeDistribution::new());
239252
}
253+
// The dispatch that is running was built from the shapes the profile held when the ISEQ
254+
// was last compiled. Remember how many that was the first time we sample: because this
255+
// path only ever grows the distribution through `observe_stable`, every bucket at or
256+
// past that index is a shape folded in since, which the running code has no arm for.
257+
let dispatch_shapes = match entry.ivar_dispatch_shapes {
258+
Some(shapes) => shapes,
259+
None => {
260+
let shapes = entry.opnd_types[0].each_item().count() as u8;
261+
entry.ivar_dispatch_shapes = Some(shapes);
262+
shapes
263+
}
264+
};
240265
let ty = ProfiledType::new(recv);
241-
if !entry.opnd_types[0].each_item().any(|seen| seen.shape() == ty.shape()) {
242-
// A shape the profile the compiled dispatch was built from has never seen. Fold it
243-
// in so a recompile can give it an arm, and remember that this window found one.
244-
// The distribution keeps the class alive for as long as the profile does.
245-
VALUE::from(iseq).write_barrier(ty.class());
246-
entry.opnd_types[0].observe(ty);
247-
entry.ivar_fallback_new_shape = true;
248-
}
266+
let fixable = if ty.shape().is_complex() {
267+
// Too-complex shapes keep their ivars in a hash table, so the dispatch would filter
268+
// this one back out. Recording it would only cost a bucket a fixable shape could use.
269+
false
270+
} else {
271+
// De-duplicate by shape, the way the dispatch itself does.
272+
let bucket = entry.opnd_types[0].observe_stable(ty, |seen, ty| seen.shape() == ty.shape());
273+
if let StableBucket::Inserted(_) = bucket {
274+
// The distribution keeps the class alive for as long as the profile does.
275+
VALUE::from(iseq).write_barrier(ty.class());
276+
}
277+
match bucket {
278+
StableBucket::Existing(index) | StableBucket::Inserted(index) => index >= dispatch_shapes as usize,
279+
// No bucket left to record this shape in, so no recompile can specialize it.
280+
// It argues against spending a version on this site, not for one.
281+
StableBucket::Full => false,
282+
}
283+
};
249284
entry.ivar_fallback_samples = entry.ivar_fallback_samples.saturating_add(1);
285+
if fixable {
286+
entry.ivar_fallback_fixable = entry.ivar_fallback_fixable.saturating_add(1);
287+
}
250288
if entry.ivar_fallback_samples < IVAR_REPROFILE_WINDOW {
251289
return IvarReprofiled::Sampled;
252290
}
253291
entry.ivar_fallback_samples = 0;
254-
if std::mem::take(&mut entry.ivar_fallback_new_shape) {
292+
let fixable = std::mem::take(&mut entry.ivar_fallback_fixable);
293+
if fixable * 100 >= IVAR_REPROFILE_WINDOW * IVAR_REPROFILE_MIN_SHARE_PERCENT {
294+
// The recompile rebuilds the dispatch from the whole profile, so start the next
295+
// window measuring against all of it.
296+
entry.ivar_dispatch_shapes = None;
255297
IvarReprofiled::Recompile
256298
} else {
257-
// Everything this window saw is already in the profile, so the fallback is being
258-
// taken for a reason a recompile cannot fix (a too-complex shape, or an arm the
259-
// dispatch dropped). Keep sampling in case that changes, but do not spend a version.
260-
IvarReprofiled::Sampled
299+
// Most of what the fallback handles is something a recompile cannot take away: a
300+
// shape already in the dispatch (whose arm was dropped, or which is unspecializable),
301+
// or one of so many shapes that the profile has no room left for them. Do not spend a
302+
// version to move a few percent of it.
303+
crate::stats::incr_counter!(ivar_respecialize_declined_count);
304+
IvarReprofiled::Declined
261305
}
262306
}
263307
}
@@ -283,7 +327,9 @@ impl IseqProfile {
283327
/// Testing it also disarms frames still running the invalidated code.
284328
#[unsafe(no_mangle)]
285329
pub extern "C" fn rb_zjit_ivar_reprofile(version: *mut crate::payload::IseqVersion, frame_iseq: VALUE, insn_idx: u32, recv: VALUE) {
286-
if unsafe { (*version).is_invalidated() } {
330+
// Both of these are one load off `version`, which keeps the give-up path from paying for the
331+
// payload lookup and the profile timer below.
332+
if unsafe { (*version).is_invalidated() || (*version).ivar_reprofile_windows == 0 } {
287333
return;
288334
}
289335
// Immediates have no shape to specialize and would poison the profile with a bucket the
@@ -296,8 +342,26 @@ pub extern "C" fn rb_zjit_ivar_reprofile(version: *mut crate::payload::IseqVersi
296342
let reprofiled = with_time_stat(profile_time_ns, || {
297343
get_or_create_iseq_payload(frame_iseq).profile.observe_ivar_fallback(frame_iseq, insn_idx, recv)
298344
});
299-
if reprofiled == IvarReprofiled::Sampled {
300-
return;
345+
match reprofiled {
346+
IvarReprofiled::Sampled => return,
347+
IvarReprofiled::Declined => {
348+
// Spend one of the version's windows. When they run out the sampling call stays in
349+
// the code, but every execution of it stops at the check above.
350+
let windows = unsafe { &mut (*version).ivar_reprofile_windows };
351+
*windows -= 1;
352+
if *windows == 0 {
353+
crate::stats::incr_counter!(ivar_respecialize_giveup_count);
354+
// Leave the sampling out of whatever this ISEQ compiles next. Invalidating just
355+
// to drop it is not worth a compile -- the call is already a no-op after the
356+
// check above -- but a version compiled for any other reason should not pay for
357+
// evidence this ISEQ has already gathered and rejected.
358+
// The flag belongs to the unit that compiled the call, which is what
359+
// `Function::emit_ivar_reprofile` tests, not the frame the site came from.
360+
get_or_create_iseq_payload(unsafe { (*version).iseq }).ivar_reprofile_giveup = true;
361+
}
362+
return;
363+
}
364+
IvarReprofiled::Recompile => {}
301365
}
302366
// Read the compiled unit's ISEQ out before taking the lock: `version` points into the
303367
// payload, and holding a reference to it across the lock's unwind boundary is not allowed.
@@ -507,8 +571,11 @@ pub struct ProfileEntry {
507571
/// Receivers seen on this ivar site's fallback path in the current re-profiling window.
508572
/// See [`rb_zjit_ivar_reprofile`].
509573
ivar_fallback_samples: u32,
510-
/// Whether the current re-profiling window has seen a shape the profile did not have.
511-
ivar_fallback_new_shape: bool,
574+
/// How many of those a recompile would give an arm of its own.
575+
ivar_fallback_fixable: u32,
576+
/// Number of shapes the dispatch now running was compiled from, i.e. how many buckets of
577+
/// `opnd_types[0]` it has arms for. `None` until the first sample after a compile.
578+
ivar_dispatch_shapes: Option<u8>,
512579
}
513580

514581
#[derive(Debug)]
@@ -551,7 +618,8 @@ impl IseqProfile {
551618
opnd_types: Vec::new(),
552619
profiles_remaining: get_option!(num_profiles),
553620
ivar_fallback_samples: 0,
554-
ivar_fallback_new_shape: false,
621+
ivar_fallback_fixable: 0,
622+
ivar_dispatch_shapes: None,
555623
});
556624
&mut self.entries[i]
557625
}

zjit/src/stats.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,8 @@ make_counters! {
159159
jit_frame_heap_bytes,
160160
failed_jit_frame_bytes,
161161
ivar_respecialize_count,
162+
ivar_respecialize_declined_count,
163+
ivar_respecialize_giveup_count,
162164
skipped_native_stack_full,
163165
skipped_exceptional_entry_escaped_env,
164166

0 commit comments

Comments
 (0)