Skip to content

Commit 3b794a3

Browse files
committed
ZJIT: Break down zjit_alloc_bytes by subsystem
zjit_alloc_bytes is a single number from the global allocator wrapper in the jit crate, so it says how much ZJIT allocated but not what for. That matters because --zjit-mem-size caps code_region_bytes + zjit_alloc_bytes together: on a large application the non-code metadata can be several times the size of the generated code, and once the sum crosses the budget compilation starts failing with out_of_memory mid-warmup. Walk the structures ZJIT retains and attribute their bytes to a subsystem, reported as mem_* keys in RubyVM::ZJIT.stats and printed with --zjit-stats. Vec-backed sizes are exact; hash tables use a documented approximation of what hashbrown asks the allocator for. Whatever the walk does not reach shows up as mem_unaccounted_bytes, which is what makes a leak visible rather than invisible. Also count allocated_iseq_payload_count and dead_iseq_payload_count so payloads retained for ISEQs the GC has already freed are visible. Pulled into zjit/min as the dependency the memory-diet commits account through. Extended for the subsystems this branch has that the original did not: the ivar shape tables, the send class tables, the side-exit metadata table and the deduplicated root ISEQ set each get a byte category and a count, and the profile's heap size covers all of min's side tables (`send_mid`, `forwarded_cis`, `block_handlers`, `block_fallbacks`) plus the dense `marked_objects` array. `JITFrame::mark` is not reinstated -- ckpt-13 replaced it with `RootIseqs` -- and `GcOffsets::heap_size()` stands in for the raw `Vec::capacity` accounting the original did.
1 parent e6df8ea commit 3b794a3

13 files changed

Lines changed: 432 additions & 0 deletions

File tree

zjit.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,21 @@ def stats_string
219219
:inlined_code_bytes,
220220
:outlined_code_bytes,
221221
:zjit_alloc_bytes,
222+
:mem_profile_bytes,
223+
:mem_iseq_payload_bytes,
224+
:mem_iseq_version_bytes,
225+
:mem_gc_offset_bytes,
226+
:mem_iseq_call_bytes,
227+
:mem_invariant_bytes,
228+
:mem_jit_frame_bytes,
229+
:mem_code_block_bytes,
230+
:mem_stats_counter_bytes,
231+
:mem_ivar_cache_bytes,
232+
:mem_send_cache_bytes,
233+
:mem_exit_meta_bytes,
234+
:mem_root_iseq_bytes,
235+
:mem_accounted_bytes,
236+
:mem_unaccounted_bytes,
222237
:total_mem_bytes,
223238
:total_native_stack_bytes,
224239

zjit/src/asm/mod.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,32 @@ impl CodeBlock {
187187
pos >= self.outlined_start
188188
}
189189

190+
/// Bytes this CodeBlock's bookkeeping owns on the Rust heap. Does not
191+
/// include the executable memory itself, which is reported separately as
192+
/// `code_region_bytes`.
193+
pub fn heap_size(&self) -> usize {
194+
let mut bytes = self.label_addrs.capacity() * size_of::<usize>()
195+
+ self.label_names.capacity() * size_of::<String>()
196+
+ self.label_refs.capacity() * size_of::<LabelRef>();
197+
for name in self.label_names.iter() {
198+
bytes += name.capacity();
199+
}
200+
// BTreeMap nodes hold up to 11 key/value pairs; approximate a node as
201+
// that many pairs plus the internal edge array.
202+
const BTREE_NODE_CAPACITY: usize = 11;
203+
let comment_nodes = self.asm_comments.len().div_ceil(BTREE_NODE_CAPACITY);
204+
bytes += comment_nodes
205+
* (BTREE_NODE_CAPACITY * (size_of::<usize>() + size_of::<Vec<String>>())
206+
+ (BTREE_NODE_CAPACITY + 1) * size_of::<usize>());
207+
for comments in self.asm_comments.values() {
208+
bytes += comments.capacity() * size_of::<String>();
209+
for comment in comments.iter() {
210+
bytes += comment.capacity();
211+
}
212+
}
213+
bytes
214+
}
215+
190216
/// Add an assembly comment if the feature is on.
191217
pub fn add_comment(&mut self, comment: &str) {
192218
if !self.keep_comments {

zjit/src/distribution.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,12 @@ impl<T: Copy + PartialEq + Default, const N: usize> Distribution<T, N> {
9999
}
100100

101101
/// Every item in a non-empty bucket, bucket 0 first.
102+
/// How many buckets hold an observed item. Zero means nothing was observed;
103+
/// `N` plus a non-zero `other` means megamorphic.
104+
pub fn num_buckets_used(&self) -> usize {
105+
self.counts.iter().filter(|&&count| count > 0).count()
106+
}
107+
102108
pub fn each_item(&self) -> impl Iterator<Item = T> + '_ {
103109
self.buckets.iter().zip(self.counts.iter())
104110
.filter_map(|(&bucket, &count)| if count > 0 { Some(bucket) } else { None })

zjit/src/gc.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,12 @@ impl RootIseqs {
203203
pub fn len(&self) -> usize {
204204
self.iseqs.len()
205205
}
206+
207+
/// Bytes this set owns on the Rust heap.
208+
pub fn heap_size(&self) -> usize {
209+
self.iseqs.capacity() * size_of::<VALUE>()
210+
+ crate::mem_stats::hash_table_bytes::<VALUE>(self.seen.capacity())
211+
}
206212
}
207213

208214
/// Note that a root table entry now points at `iseq`. See [`RootIseqs`].
@@ -302,6 +308,7 @@ pub extern "C" fn rb_zjit_iseq_free(iseq: IseqPtr) {
302308

303309
// TODO(Shopify/ruby#682): Free `IseqPayload`
304310
let payload = get_or_create_iseq_payload(iseq);
311+
crate::stats::incr_counter!(dead_iseq_payload_count);
305312
for version in payload.versions.iter_mut() {
306313
unsafe { version.as_mut() }.iseq = null();
307314
}

zjit/src/invariants.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,45 @@ pub struct Invariants {
117117
}
118118

119119
impl Invariants {
120+
/// Bytes these patch-point tables own on the Rust heap, and the number of
121+
/// patch points they hold. Every assumption is stored as a
122+
/// `HashMap<Key, HashSet<PatchPoint>>` or a bare `HashSet<PatchPoint>`, so
123+
/// both the outer tables and each inner set are counted.
124+
pub fn heap_size(&self) -> (usize, usize) {
125+
use crate::mem_stats::hash_table_bytes;
126+
127+
let mut bytes = 0;
128+
let mut count = 0;
129+
130+
macro_rules! account_map {
131+
($map:expr, $key:ty) => {
132+
bytes += hash_table_bytes::<($key, HashSet<PatchPoint>)>($map.capacity());
133+
for set in $map.values() {
134+
bytes += hash_table_bytes::<PatchPoint>(set.capacity());
135+
count += set.len();
136+
}
137+
};
138+
}
139+
macro_rules! account_set {
140+
($set:expr) => {
141+
bytes += hash_table_bytes::<PatchPoint>($set.capacity());
142+
count += $set.len();
143+
};
144+
}
145+
146+
account_map!(self.no_ep_escape_iseq_patch_points, IseqPtr);
147+
account_map!(self.bop_patch_points, (RedefinitionFlag, ruby_basic_operators));
148+
account_map!(self.cme_patch_points, *const rb_callable_method_entry_t);
149+
account_map!(self.constant_state_patch_points, ID);
150+
account_map!(self.no_singleton_class_patch_points, VALUE);
151+
account_set!(self.no_trace_point_patch_points);
152+
account_set!(self.no_newobj_hook_patch_points);
153+
account_set!(self.single_ractor_patch_points);
154+
account_set!(self.root_box_patch_points);
155+
156+
(bytes, count)
157+
}
158+
120159
/// Update object references in Invariants
121160
pub fn update_references(&mut self) {
122161
// Keys are class VALUEs that compaction may have moved.

zjit/src/jit_frame.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,12 @@ impl JITFrame {
125125
Self::alloc(pc, iseq, materialize_block_code, stack_size)
126126
}
127127

128+
/// Bytes this frame occupies on the Rust heap, including the trailing
129+
/// stack map that [`Self::alloc`] over-allocated for.
130+
pub fn heap_size(&self) -> usize {
131+
size_of::<JITFrame>() + self.stack_size as usize * size_of::<VALUE>()
132+
}
133+
128134
/// Update the iseq pointer after GC compaction.
129135
pub fn update_references(&mut self) {
130136
if !self.iseq.is_null() {

zjit/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ mod exit_meta;
3838
mod payload;
3939
mod ivar_cache;
4040
mod send_cache;
41+
mod mem_stats;
4142
mod json;
4243
mod ttycolors;
4344

zjit/src/mem_stats.rs

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
//! Breakdown of `zjit_alloc_bytes` (ZJIT's Rust heap usage) by subsystem.
2+
//!
3+
//! `zjit_alloc_bytes` is a single number produced by the global allocator
4+
//! wrapper in the `jit` crate, so it tells you *how much* ZJIT has allocated
5+
//! but not *what for*. On large applications the non-code metadata is several
6+
//! times the size of the generated code, and `--zjit-mem-size` caps the sum of
7+
//! the two, so knowing the composition is the difference between guessing and
8+
//! fixing.
9+
//!
10+
//! This module walks the structures ZJIT retains and reports the bytes each
11+
//! subsystem owns. Vec-backed sizes are exact (capacity times element size);
12+
//! hash-table sizes are close approximations of what hashbrown asks the
13+
//! allocator for (see [`hash_table_bytes`]). The residual between
14+
//! `zjit_alloc_bytes` and the sum of the categories is reported as
15+
//! `mem_unaccounted_bytes`.
16+
17+
use crate::cruby::{IseqPtr, for_each_iseq, rb_iseq_get_jit_payload};
18+
use crate::payload::IseqPayload;
19+
use crate::state::ZJITState;
20+
21+
/// Number of control bytes hashbrown allocates past the end of the bucket
22+
/// array. This is `Group::WIDTH`, 16 on x86-64 (SSE2) and aarch64 (NEON).
23+
const HASHBROWN_GROUP_WIDTH: usize = 16;
24+
25+
/// Approximate the bytes hashbrown (the backing store of `std`'s `HashMap` and
26+
/// `HashSet`) requests from the allocator for a table whose `capacity()` is
27+
/// `capacity` and whose element type is `T`.
28+
///
29+
/// hashbrown rounds the requested capacity up to a power-of-two bucket count
30+
/// and allocates `buckets * size_of::<T>()` for the entries plus
31+
/// `buckets + Group::WIDTH` control bytes. Padding for alignment is ignored, so
32+
/// this can be a few bytes low per table.
33+
pub fn hash_table_bytes<T>(capacity: usize) -> usize {
34+
if capacity == 0 {
35+
return 0;
36+
}
37+
// Inverse of hashbrown's capacity_to_buckets(): buckets 4 hold 3 items,
38+
// buckets 8 hold 7, and beyond that capacity is 7/8 of the bucket count.
39+
let buckets = if capacity <= 3 {
40+
4
41+
} else if capacity <= 7 {
42+
8
43+
} else {
44+
(capacity * 8 / 7).next_power_of_two()
45+
};
46+
buckets * size_of::<T>() + buckets + HASHBROWN_GROUP_WIDTH
47+
}
48+
49+
/// Bytes retained by each ZJIT subsystem, in the same units as
50+
/// `zjit_alloc_bytes`.
51+
#[derive(Default, Debug)]
52+
pub struct MemoryBreakdown {
53+
/// `IseqPayload` structs, one per ISEQ ZJIT has ever touched.
54+
pub iseq_payload_bytes: usize,
55+
/// Per-instruction profiling data (type/shape distributions) inside payloads.
56+
pub profile_bytes: usize,
57+
/// `IseqVersion` structs plus the `Vec` of version pointers in each payload.
58+
pub iseq_version_bytes: usize,
59+
/// GC offset tables (addresses of `VALUE`s baked into JIT code).
60+
pub gc_offset_bytes: usize,
61+
/// JIT-to-JIT call metadata: `IseqCall` allocations and the incoming and
62+
/// outgoing edge vectors that point at them.
63+
pub iseq_call_bytes: usize,
64+
/// Patch-point tables used to invalidate speculative code (`Invariants`).
65+
pub invariant_bytes: usize,
66+
/// `JITFrame`s: compile-time frame metadata plus their trailing stack maps.
67+
pub jit_frame_bytes: usize,
68+
/// `CodeBlock` bookkeeping: label tables and (with `--zjit-dump-disasm`)
69+
/// assembly comments.
70+
pub code_block_bytes: usize,
71+
/// String-keyed counter tables that only `--zjit-stats` populates.
72+
pub stats_counter_bytes: usize,
73+
/// Shape tables for ivar accesses that miss their inline guard chain.
74+
pub ivar_cache_bytes: usize,
75+
/// Class tables for send sites that dispatch over more classes than an
76+
/// inline guard chain can cover.
77+
pub send_cache_bytes: usize,
78+
/// Interpreter state for compiled side exits. Trading these bytes for
79+
/// executable ones is the point of the exercise: they used to be immediates
80+
/// in the exit stubs. See [`crate::exit_meta`].
81+
pub exit_meta_bytes: usize,
82+
/// The deduplicated set of ISEQs the JITFrame and ExitMeta tables reference,
83+
/// which is what the GC mark phase walks in their place. See
84+
/// [`crate::gc::RootIseqs`].
85+
pub root_iseq_bytes: usize,
86+
87+
/// Number of ISEQ payloads walked, for per-ISEQ math.
88+
pub payload_count: usize,
89+
/// Number of `IseqVersion`s reachable from those payloads.
90+
pub version_count: usize,
91+
/// Number of per-instruction profile entries in those payloads.
92+
pub profile_entry_count: usize,
93+
/// Bytes sitting in the unused tail of profile `entries` vectors.
94+
pub profile_entry_slack_bytes: usize,
95+
/// Number of operand type distributions across all profiles.
96+
pub profile_distribution_count: usize,
97+
/// How many of those distributions saw at most one type.
98+
pub profile_monomorphic_distribution_count: usize,
99+
/// Number of patch points in `Invariants`.
100+
pub patch_point_count: usize,
101+
/// Number of live `JITFrame`s.
102+
pub jit_frame_count: usize,
103+
/// Number of ivar shape tables, i.e. distinct ivar names with one.
104+
pub ivar_cache_count: usize,
105+
/// Number of send class tables, i.e. distinct call shapes with one.
106+
pub send_cache_count: usize,
107+
/// Number of interned `ExitMeta` records.
108+
pub exit_meta_count: usize,
109+
/// Number of distinct ISEQs in the root set. The ratio against
110+
/// `jit_frame_count + 2 * exit_meta_count` is what deduplication saves the
111+
/// mark phase on every collection.
112+
pub root_iseq_count: usize,
113+
/// Number of objects in the dense arrays GC marking walks, i.e. the distinct
114+
/// objects the profiles reference. See
115+
/// [`crate::profile::IseqProfile::marked_objects`].
116+
pub profile_marked_object_count: usize,
117+
}
118+
119+
impl MemoryBreakdown {
120+
/// Sum of every byte category above.
121+
pub fn accounted_bytes(&self) -> usize {
122+
self.iseq_payload_bytes
123+
+ self.profile_bytes
124+
+ self.iseq_version_bytes
125+
+ self.gc_offset_bytes
126+
+ self.iseq_call_bytes
127+
+ self.invariant_bytes
128+
+ self.jit_frame_bytes
129+
+ self.code_block_bytes
130+
+ self.stats_counter_bytes
131+
+ self.ivar_cache_bytes
132+
+ self.send_cache_bytes
133+
+ self.exit_meta_bytes
134+
+ self.root_iseq_bytes
135+
}
136+
}
137+
138+
/// Walk everything ZJIT retains on the Rust heap and attribute it to a
139+
/// subsystem. Requires the VM lock (it iterates over every live ISEQ).
140+
pub fn memory_breakdown() -> MemoryBreakdown {
141+
let mut out = MemoryBreakdown::default();
142+
143+
// Per-ISEQ payloads. Only ISEQs that are still alive are visited; ZJIT
144+
// currently never frees the payload of a dead ISEQ, so those bytes show up
145+
// in mem_unaccounted_bytes rather than here.
146+
for_each_iseq(|iseq: IseqPtr| {
147+
let payload = unsafe { rb_iseq_get_jit_payload(iseq) } as *const IseqPayload;
148+
if payload.is_null() {
149+
return;
150+
}
151+
let payload = unsafe { &*payload };
152+
out.payload_count += 1;
153+
out.iseq_payload_bytes += size_of::<IseqPayload>();
154+
155+
let profile = payload.profile.heap_size();
156+
out.profile_bytes += profile.bytes;
157+
out.profile_entry_count += profile.entry_count;
158+
out.profile_entry_slack_bytes += profile.entry_slack_bytes;
159+
out.profile_distribution_count += profile.distribution_count;
160+
out.profile_monomorphic_distribution_count += profile.monomorphic_distribution_count;
161+
out.profile_marked_object_count += profile.marked_object_count;
162+
163+
out.iseq_version_bytes += payload.versions.capacity() * size_of::<crate::payload::IseqVersionRef>();
164+
for version in payload.versions.iter() {
165+
let version = unsafe { version.as_ref() };
166+
out.version_count += 1;
167+
out.iseq_version_bytes += size_of::<crate::payload::IseqVersion>();
168+
out.gc_offset_bytes += version.gc_offsets.heap_size();
169+
out.iseq_call_bytes += version.iseq_call_heap_size();
170+
}
171+
});
172+
173+
let invariants = ZJITState::get_invariants();
174+
let (invariant_bytes, patch_point_count) = invariants.heap_size();
175+
out.invariant_bytes = invariant_bytes;
176+
out.patch_point_count = patch_point_count;
177+
178+
let jit_frames = ZJITState::get_jit_frames();
179+
out.jit_frame_count = jit_frames.len();
180+
out.jit_frame_bytes = jit_frames.capacity() * size_of::<*mut crate::jit_frame::JITFrame>()
181+
+ jit_frames.iter().map(|&frame| unsafe { &*frame }.heap_size()).sum::<usize>();
182+
183+
let exit_metas = ZJITState::get_exit_metas();
184+
out.exit_meta_count = exit_metas.len();
185+
out.exit_meta_bytes = exit_metas.capacity() * size_of::<crate::exit_meta::ExitMeta>();
186+
187+
let root_iseqs = ZJITState::get_root_iseqs();
188+
out.root_iseq_count = root_iseqs.len();
189+
out.root_iseq_bytes = root_iseqs.heap_size();
190+
191+
let ivar_caches = ZJITState::get_ivar_caches();
192+
out.ivar_cache_count = ivar_caches.len();
193+
out.ivar_cache_bytes = hash_table_bytes::<(crate::cruby::ID, Box<crate::ivar_cache::IvarCache>)>(ivar_caches.capacity())
194+
+ ivar_caches.values().map(|cache| size_of::<crate::ivar_cache::IvarCache>() + cache.heap_size()).sum::<usize>();
195+
196+
let send_caches = ZJITState::get_send_caches();
197+
out.send_cache_count = send_caches.len();
198+
out.send_cache_bytes = crate::send_cache::send_caches_heap_size(send_caches);
199+
200+
out.code_block_bytes = ZJITState::get_code_block().heap_size();
201+
out.stats_counter_bytes = ZJITState::counter_table_heap_size();
202+
203+
out
204+
}

zjit/src/payload.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,20 @@ pub const MAX_IVAR_REPROFILE_WINDOWS: u8 = 4;
162162
pub type IseqVersionRef = NonNull<IseqVersion>;
163163

164164
impl IseqVersion {
165+
/// Bytes the JIT-to-JIT call bookkeeping of this version owns on the Rust
166+
/// heap: the incoming and outgoing edge vectors, plus the `IseqCall`
167+
/// allocations themselves. Each `IseqCall` is created by its caller and
168+
/// pushed onto that caller's `outgoing`, so counting only `outgoing`
169+
/// attributes every allocation exactly once.
170+
pub fn iseq_call_heap_size(&self) -> usize {
171+
use crate::codegen::IseqCall;
172+
// Rc<T> allocates two counters ahead of the value.
173+
let rc_bytes = 2 * size_of::<usize>() + size_of::<IseqCall>();
174+
self.outgoing.capacity() * size_of::<IseqCallRef>()
175+
+ self.incoming.capacity() * size_of::<IseqCallRef>()
176+
+ self.outgoing.len() * rc_bytes
177+
}
178+
165179
/// Check if this version was invalidated
166180
pub fn is_invalidated(&self) -> bool {
167181
self.status == IseqStatus::Invalidated
@@ -214,6 +228,7 @@ pub fn get_or_create_iseq_payload_ptr(iseq: IseqPtr) -> *mut IseqPayload {
214228
// We allocate in those cases anyways.
215229
let new_payload = IseqPayload::new();
216230
let new_payload = Box::into_raw(Box::new(new_payload));
231+
crate::stats::incr_counter!(allocated_iseq_payload_count);
217232
rb_iseq_set_jit_payload(iseq, new_payload as VoidPtr);
218233

219234
new_payload

0 commit comments

Comments
 (0)