Track heap profiler liveness with ObjectSpace::WeakMap instead of _id2ref - #6176
Track heap profiler liveness with ObjectSpace::WeakMap instead of _id2ref#6176eregon wants to merge 3 commits into
Conversation
🎉 All green!🧪 All tests passed 🎯 Code Coverage (details) 🔗 Commit SHA: 74820ac | Docs | Datadog PR Page | Give us feedback! |
BenchmarksBenchmark execution time: 2026-08-13 14:28:43 Comparing candidate commit 74820ac in PR branch Found 0 performance improvements and 0 performance regressions! Performance is the same for 48 metrics, 1 unstable metrics.
|
| VALUE ref; | ||
|
|
||
| if (!ruby_ref_from_id(LONG2NUM(record->obj_id), &ref)) { | ||
| if (!ruby_weak_map_get(recorder->weak_objects, LONG2FIX(record->record_id), &ref)) { |
There was a problem hiding this comment.
LONG2NUM->LONG2FIX here, that's only OK if all record IDs are always in Fixnum range.
But also it's unsafe for us to use Bignum in the weakmap because then we'd need to hold onto them, otherwise they could GC and remove the entry altogether.
| @@ -162,16 +159,9 @@ size_t rb_obj_memsize_of(VALUE obj); | |||
| size_t ruby_obj_memsize_of(VALUE obj) { | |||
There was a problem hiding this comment.
Maybe we can completely remove this now, though there is still a special case for T_NODE.
I haven't looked if that crash was related to _id2ref + memsize issues on Ruby 4.0.
| # Doing this on the main thread instead made these examples flaky on CI: the object would survive the | ||
| # GC below and the recorder would (correctly!) keep reporting it. That's the same class of problem the | ||
| # enclosing `before` documents, see https://bugs.ruby-lang.org/issues/19460. | ||
| record_id = Thread.new { sample_allocation(Object.new) }.value |
There was a problem hiding this comment.
This seems actually a really cool trick to reliably not hold onto an Object, which is otherwise very difficult to achieve in CRuby (from ruby/spec experience).
| let(:heap_size_enabled) { true } | ||
|
|
||
| before do | ||
| skip "Heap profiling is only supported on Ruby >= 2.7" unless RubyVersion.is?(">= 2.7") |
There was a problem hiding this comment.
This was outdated, heap profiling was already 3.1+
|
I self-reviewed, it looks pretty reasonable and not so big, ready for other 👀 to review. |
…2ref `ObjectSpace._id2ref` is a dead end for the heap profiler: it was removed in Ruby 4.1 ([Feature #22135]), is deprecated in 4.0 ([Feature #15408]), and has a history of crashes -- a SEGV in its own error path (fixed only in 4.0.7+) and returning unrelated live objects under lazy sweeping ([Bug #22200]). Each heap recorder now owns an `ObjectSpace::WeakMap` mapping `record_id -> object`. A WeakMap entry is dropped as soon as either side is collected, so pairing an immortal fixnum key with the tracked object as the (weak) value gives us exactly the weak reference we need: reading the key back returns the object while it's alive, and nil once it's gone. This also replaces a `rb_rescue2` + raise-per-dead-object with a plain lookup. While here, drop `rb_obj_id` from the heap path in favour of a per-recorder monotonic counter. Asking for an object_id mutates the profiled application's objects -- a shape/fields transition on Ruby 4, and two st_table entries plus FL_SEEN_OBJ_ID on Ruby 3 -- so not needing it makes heap profiling less intrusive. It also lets us drop NO_IMEMO_OBJECT_ID (Ruby 4 was silently dropping IMEMO heap samples), the bignum-exhaustion failure mode, and the duplicate-id check, since ids are now unique by construction. Deferred recording becomes unconditional. `WeakMap#[]=` is a Ruby method call that allocates, and neither is safe inside the newobj tracepoint on Ruby < 4.0: `ruby_xmalloc` can trigger a GC there, as Ruby only added `rb_gc_local_disable_no_rest()` around the hook in 4.0. So the object reference is always buffered and the weak map entry is committed later from the after_allocation postponed job. This removes the USE_DEFERRED_HEAP_ALLOCATION_RECORDING split rather than widening it, and needs a `rb_postponed_job_register_one` fallback for Ruby < 3.3. It also restores the "consecutive recording starts without end" guard, which was a no-op in deferred mode. Note that buffer-full drops (`deferred_recordings_skipped_buffer_full`) now become possible on Ruby < 4, which previously committed inline. Heap profiling is force-disabled on Ruby 3.1.0-3.1.3 and 3.2.0-3.2.2, where `ObjectSpace::WeakMap` can corrupt its internal state during compaction ([Bug #19529], fixed in 3.1.4 and 3.2.3). The Ruby 4.1 gate is removed. Note that enabling heap profiling on Ruby 4.1 end to end additionally needs a `datadog-ruby_core_source` release with 4.1 headers, which the profiler extension does not build without today. Two spec-side notes: * The heap specs now skip below Ruby 3.1, matching what `Profiling::Component.enable_heap_profiling?` supports. The check has to run before the enclosing `before` samples its allocations, since those already go through the heap recorder -- and on Ruby < 2.7 `ObjectSpace::WeakMap#[]=` rejects keys that can't have a finalizer defined on them, which is what our fixnum keys are. * `#recorder_after_gc_step` allocates and samples its objects on a throwaway thread. Once that thread is joined its stack and registers are gone, so there is nowhere for a stale reference to hide from Ruby's conservative garbage collector; only the record id crosses back. Doing this on the main thread made those examples flaky on x86_64 (2 of 12 runs on Ruby 3.2), because an object would survive the GC and the recorder would then quite correctly keep reporting it -- the same class of problem https://bugs.ruby-lang.org/issues/19460 causes elsewhere in that file. Verified: full profiling suite on 2.5.9, 3.1.7 (docker), 2.6.10, 2.7.8, 3.2.11, 3.3.11, 3.4.9 and 4.0.6, covering both WeakMap implementations (finalizer-based on 3.1/3.2, weak-reference-based on 3.3+), plus 25 runs of the heap cleanup examples on x86_64 Ruby 3.2 and 20 on 3.1. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This reverts #6022, which disabled the "Heap Live Size" profile type on Ruby 4 after the SIGSEGV reports in #5936. The root cause of those crashes was `ObjectSpace._id2ref`: the recorder resurrected each tracked object through it and then called `rb_obj_memsize_of` on the result. `_id2ref` could hand back a dead or entirely unrelated object (see [Bug #22200]), and computing the memsize of one of those walks garbage -- for class-like objects that means `rb_class_classext_foreach` -> `classext_memsize` on invalid classext memory, which matches both crash signatures reported in #5936 (`st_object_record_update` and `classext_memsize`). Now that liveness comes from an `ObjectSpace::WeakMap`, a lookup either returns a genuinely live object or nothing at all, so there is no longer a path that feeds a bogus object to `rb_obj_memsize_of`. Note that the narrower workaround from #5938 (`NO_SAFE_CLASS_MEMSIZE`, which skips memsize for T_CLASS/T_MODULE/T_ICLASS on Ruby 4) is kept for now, so class objects still report a size of 0 there. By the same reasoning it should no longer be needed either, but that is left as a separate change. Verified: full profiling suite on 3.1.7 (docker), 3.2.11, 3.3.11, 3.4.9 and 4.0.6. On 4.0.6 the end-to-end assertion in cpu_and_wall_time_worker_spec that sums `heap-live-size` across samples now runs again and passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This reverts #5938, which skipped `rb_obj_memsize_of` for T_CLASS/T_MODULE/T_ICLASS on Ruby 4 so that heap size profiling would report 0 bytes for them instead of risking the SIGSEGV from #5936. Same reasoning as the previous commit: that crash needed an invalid object, which is what `ObjectSpace._id2ref` could hand us. The call path is `rb_obj_memsize_of` -> `rb_class_classext_foreach`, which reads `RCLASS_CLASSEXT_TBL(klass)` and `st_foreach`es it; `classext_memsize` itself only null-checks each table and reads scalar fields, so it is perfectly safe on a genuinely live class and dereferences garbage on anything else. #6022 noting that the crashes had spread to "many different kinds of objects, not just classes" is further evidence the object was the problem, not the classext walk. The regression test from #5938 is kept, with its Ruby 4 branch dropped: it now asserts a real `ObjectSpace.memsize_of` on every supported Ruby, so it would fail if we ever started skipping class sizes again. Verified on 4.0.6, beyond the spec suite: * 8000 Class/Module/singleton-class objects tracked, sized, and serialized over 5 rounds of GC plus GC.compact, repeated 3 times -- no crash, sizes match `ObjectSpace.memsize_of`. * The same shape under `GC.stress` (a GC on every allocation). * The real profiler end to end with the exact configuration from #5936 (allocation + experimental_heap_enabled, heap size left at its default). Full profiling suite green on 3.1.7 (docker), 3.2.11, 3.3.11, 3.4.9 and 4.0.6. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
It works fine |
|
Benchmark on Ruby 3.4 & 3.2, on macOS: Benchmarks:
|
| measurement | branch (WeakMap) | baseline (_id2ref) |
Δ |
|---|---|---|---|
profiling_allocation, no profiler (control) |
24.151, 24.148, 24.202 → 24.167M i/s | 24.010, 23.925, 23.983 → 23.973M i/s | +0.8% (noise floor) |
profiling_allocation, heap on |
14.632, 14.728, 14.738 → 14.699M i/s | 14.627, 14.825, 14.590 → 14.681M i/s | +0.1% |
sample+serialize, RETAIN_EVERY=10 |
5.486, 5.530, 5.515 → 5.510 i/s | 5.456, 5.449, 5.467 → 5.457 i/s | +1.0% (disjoint) |
sample+serialize, RETAIN_EVERY=1 (hits) |
4.559, 4.588, 4.589 → 4.579 i/s | 4.586, 4.588, 4.575 → 4.583 i/s | −0.1% |
sample+serialize, RETAIN_EVERY=100000 (misses) |
5.647, 5.645, 5.652 → 5.648 i/s | 5.576, 5.573, 5.573 → 5.574 i/s | +1.3% (disjoint) |
Ruby 3.2.11 (finalizer-based WeakMap), 4 reps
| measurement | branch (WeakMap) | baseline (_id2ref) |
Δ |
|---|---|---|---|
profiling_allocation, no profiler (control) |
25.561, 25.661, 25.708, 25.729 → 25.665M i/s | 25.504, 25.790, 25.223, 25.555 → 25.518M i/s | +0.6% (noise floor) |
profiling_allocation, heap on |
15.335, 15.422, 15.509, 15.470 → 15.434M i/s | 15.401, 15.440, 15.299, 15.318 → 15.365M i/s | +0.4% (−0.1% normalised to own control) |
sample+serialize, RETAIN_EVERY=10 |
5.167, 5.167, 5.154, 5.151 → 5.160 i/s | 5.147, 5.173, 5.131, 5.123 → 5.144 i/s | +0.3% |
sample+serialize, RETAIN_EVERY=1 (hits) |
4.351, 4.338, 4.348, 4.245 → 4.321 i/s | 4.409, 4.387, 4.377, 4.345 → 4.380 i/s | −1.3% |
sample+serialize, RETAIN_EVERY=100000 (misses) |
5.302, 5.280, 5.286, 5.311 → 5.295 i/s | 5.257, 5.230, 5.307, 5.287 → 5.270 i/s | +0.5% |
Side by side
| Ruby 3.2 (finalizer-based WeakMap) | Ruby 3.4 (rb_gc_mark_weak) |
|
|---|---|---|
| allocation path | neutral | neutral |
| liveness lookup hits | −1.3% (slower) | −0.1% (neutral) |
| liveness lookup misses | +0.5% (marginal, ranges overlap) | +1.3% (disjoint ranges) |
| default mix (600 live / 5400 dead) | +0.3% (marginal) | +1.0% (disjoint) |
Interpretation
On Ruby 3.3+ the win comes entirely from the miss path. _id2ref signals "object is gone" by raising RangeError, so the old code paid rb_rescue2 setup plus a raise-and-rescue for every dead object; the WeakMap simply returns nil. Quantified on 3.4: 179.41 → 177.06 ms per iteration over ~5999 dead-object lookups ≈ 390 ns saved per dead-object liveness check. The default-mix +1.0% is just that win scaled by the proportion of dead records.
On Ruby 3.2 that gain is largely eaten. wmap_lookup there does more work per lookup — an is_pointer_to_heap + is_live_object check on the retrieved value, plus the obj2wmap inverse table — versus 3.3+'s bare Qundef test. So hits get ~1% slower and the miss-path gain shrinks into the noise, netting roughly neutral overall.
The allocation path is unaffected on both versions. This is the reassuring one: making recording deferred on every Ruby (buffer write + postponed job, previously Ruby 4 only) costs nothing measurable, because it also removed the rb_obj_id call that mutated every sampled object. Notably, the per-object finalizer that wmap_aset registers on Ruby < 3.3 — the known cost of supporting 3.1/3.2 with WeakMap rather than raising the minimum to 3.3 — does not show up measurably here either.
For reference, the cost of enabling heap profiling at all (unchanged by this PR): ~1.64x on BasicObject.new on 3.4 (41 ns → 68 ns) and ~1.66x on 3.2 (39 ns → 65 ns).
Caveats
- The only result I'd call solid on 3.2 is the −1.3% on hits: it pointed the same direction in two independent runs (−0.9%, then −1.3%). Everything else on 3.2 is inside the ~0.6% noise floor.
sample+serializespends ~30 µs per sample on stack walking, so the liveness check is a thin slice of each iteration. Sub-1% end-to-end movements may correspond to considerably larger relative changes in the update itself. Quantifying that would need a micro-benchmark timingheap_recorder_updatedirectly, which the current test-only API can't do because the update self-throttles (it skips when the GC generation hasn't advanced, and skips records atgen_age >= OLD_AGE).- No CPU pinning. An earlier 3.2 run was discarded because one sample came in ~70x slow (0.063 i/s vs ~4.4) during a period of machine contention; the numbers above are from a clean run at 99% CPU with no outliers.
ObjectSpace._id2refis a dead end for the heap profiler: it was removed in Ruby 4.1 ([Feature #22135]), is deprecated in 4.0 ([Feature #15408]), and has a history of crashes such as returning unrelated live objects under lazy sweeping ([Bug #22200]).Each heap recorder now owns an
ObjectSpace::WeakMapmappingrecord_id -> object. A WeakMap entry is dropped as soon as either side is collected, so pairing an immortal fixnum key with the tracked object as the (weak) value gives us exactly the weak reference we need: reading the key back returns the object while it's alive, and nil once it's gone. This also replaces arb_rescue2+ raise-per-dead-object with a plain lookup.While here, drop
rb_obj_idfrom the heap path in favour of a per-recorder monotonic counter. Asking for an object_id mutates the profiled application's objects -- a shape/fields transition on Ruby 4, and two st_table entries plus FL_SEEN_OBJ_ID on Ruby 3 -- so not needing it makes heap profiling less intrusive. It also lets us drop NO_IMEMO_OBJECT_ID (Ruby 4 was silently dropping IMEMO heap samples), the bignum-exhaustion failure mode, and the duplicate-id check, since ids are now unique by construction.Deferred recording becomes unconditional.
WeakMap#[]=is a Ruby method call that allocates, and neither is safe inside the newobj tracepoint on Ruby < 4.0:ruby_xmalloccan trigger a GC there, as Ruby only addedrb_gc_local_disable_no_rest()around the hook in 4.0. So the object reference is always buffered and the weak map entry is added later from the after_allocation postponed job. This removes the USE_DEFERRED_HEAP_ALLOCATION_RECORDING split rather than widening it, and needs arb_postponed_job_register_onefallback for Ruby < 3.3. It also restores the "consecutive recording starts without end" guard, which was a no-op in deferred mode. Note that buffer-full drops (deferred_recordings_skipped_buffer_full) now become possible on Ruby < 4, which previously committed inline.Heap profiling is force-disabled on Ruby 3.1.0-3.1.3 and 3.2.0-3.2.2, where
ObjectSpace::WeakMapcan corrupt its internal state during compaction ([Bug #19529], fixed in 3.1.4 and 3.2.3). The Ruby 4.1 guard is removed.Note that enabling heap profiling on Ruby 4.1 end to end additionally needs a
datadog-ruby_core_sourcerelease with 4.1 headers, which the profiler extension does not build without today.Verified: full profiling suite on 3.1.7 (docker), 3.2.11, 3.3.11, 3.4.9 and 4.0.6, covering both WeakMap implementations (finalizer-based on 3.1/3.2, weak-reference-based on 3.3+).
What does this PR do?
^
Motivation:
Make heap profiling work reliably.
Change log entry
Yes. Heap profiling now uses
ObjectSpace::WeakMapinstead ofObjectSpace._id2ref, avoiding many issues related toObjectSpace._id2ref. Heap live size profiling is no longer disabled on Ruby 4.0 as it works reliably now.Additional Notes:
How to test the change?