Skip to content

Track heap profiler liveness with ObjectSpace::WeakMap instead of _id2ref - #6176

Open
eregon wants to merge 3 commits into
masterfrom
bye-bye-id2ref
Open

Track heap profiler liveness with ObjectSpace::WeakMap instead of _id2ref#6176
eregon wants to merge 3 commits into
masterfrom
bye-bye-id2ref

Conversation

@eregon

@eregon eregon commented Aug 11, 2026

Copy link
Copy Markdown
Member

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 such as 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 added 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 guard 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.

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::WeakMap instead of ObjectSpace._id2ref, avoiding many issues related to ObjectSpace._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?

@dd-octo-sts dd-octo-sts Bot added the profiling Involves Datadog profiling label Aug 11, 2026
@datadog-prod-us1-4

datadog-prod-us1-4 Bot commented Aug 11, 2026

Copy link
Copy Markdown

Tests

🎉 All green!

🧪 All tests passed
❄️ No new flaky tests detected

🎯 Code Coverage (details)
Patch Coverage: 0.00%
Overall Coverage: 90.00% (+0.03%)

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 74820ac | Docs | Datadog PR Page | Give us feedback!

@pr-commenter

pr-commenter Bot commented Aug 11, 2026

Copy link
Copy Markdown

Benchmarks

Benchmark execution time: 2026-08-13 14:28:43

Comparing candidate commit 74820ac in PR branch bye-bye-id2ref with baseline commit 4850645 in branch master.

📊 Benchmarking dashboard

Found 0 performance improvements and 0 performance regressions! Performance is the same for 48 metrics, 1 unstable metrics.

Explanation

This is an A/B test comparing a candidate commit's performance against that of a baseline commit. Performance changes are noted in the tables below as:

  • 🟩 = significantly better candidate vs. baseline
  • 🟥 = significantly worse candidate vs. baseline

We compute a confidence interval (CI) over the relative difference of means between metrics from the candidate and baseline commits, considering the baseline as the reference.

If the CI is entirely outside the configured SIGNIFICANT_IMPACT_THRESHOLD (or the deprecated UNCONFIDENCE_THRESHOLD), the change is considered significant.

Feel free to reach out to #apm-benchmarking-platform on Slack if you have any questions.

More details about the CI and significant changes

You can imagine this CI as a range of values that is likely to contain the true difference of means between the candidate and baseline commits.

CIs of the difference of means are often centered around 0%, because often changes are not that big:

---------------------------------(------|---^--------)-------------------------------->
                              -0.6%    0%  0.3%     +1.2%
                                 |          |        |
         lower bound of the CI --'          |        |
sample mean (center of the CI) -------------'        |
         upper bound of the CI ----------------------'

As described above, a change is considered significant if the CI is entirely outside the configured SIGNIFICANT_IMPACT_THRESHOLD (or the deprecated UNCONFIDENCE_THRESHOLD).

For instance, for an execution time metric, this confidence interval indicates a significantly worse performance:

----------------------------------------|---------|---(---------^---------)---------->
                                       0%        1%  1.3%      2.2%      3.1%
                                                  |   |         |         |
       significant impact threshold --------------'   |         |         |
                      lower bound of CI --------------'         |         |
       sample mean (center of the CI) --------------------------'         |
                      upper bound of CI ----------------------------------'

Unstable benchmarks

These benchmarks have a confidence interval too wide to call a change; treat them as noise rather than signal.

scenario:tracing - trace.to_digest - Continue

  • unstable throughput [-1289.197op/s; +1807.448op/s] or [-4.441%; +6.227%]

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)) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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")

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was outdated, heap profiling was already 3.1+

@eregon

eregon commented Aug 13, 2026

Copy link
Copy Markdown
Member Author

I self-reviewed, it looks pretty reasonable and not so big, ready for other 👀 to review.

@eregon
eregon marked this pull request as ready for review August 13, 2026 13:58
@eregon
eregon requested review from a team as code owners August 13, 2026 13:58
eregon and others added 3 commits August 13, 2026 15:59
…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>
@eregon

eregon commented Aug 13, 2026

Copy link
Copy Markdown
Member Author

It works fine
From

DD_TRACE_DEBUG=true DD_PROFILING_EXPERIMENTAL_SHOW_CLASSES_ENABLED=true DD_PROFILING_ENABLED=true DD_PROFILING_ALLOCATION_ENABLED=true DD_PROFILING_EXPERIMENTAL_HEAP_ENABLED=true DD_PROFILING_EXPERIMENTAL_HEAP_SIZE_ENABLED=true DD_SERVICE=benoit-testing bundle exec ddprofrb exec ruby -rbenchmark -e 'def foo; Array.new(1000) { Object.new }; end; p Benchmark.realtime { 70_000.times { foo } }'

@eregon

eregon commented Aug 14, 2026

Copy link
Copy Markdown
Member Author

Benchmark on Ruby 3.4 & 3.2, on macOS:
TLDR: ~1% faster on 3.4, ~1% slower on 3.2, seems fine.

Benchmarks: ObjectSpace._id2refObjectSpace::WeakMap

Method

  • Branch vs its merge base (48506456a3), built in two separate worktrees on the same machine.
  • Runs are interleaved (branch, baseline, branch, baseline, …) within each repetition, so thermal drift affects both sides equally.
  • HEAP_SAMPLES=true HEAP_SIZE=true. Ruby 3.4.9 = 3 reps, Ruby 3.2.11 = 4 reps.
  • profiling_allocation.rb measures BasicObject.new throughput; heap profiling enabled via the new HEAP_SAMPLES option. It also reports a no-profiler control, which gives a per-run noise estimate.
  • profiling_memory_sample_serialize.rb measures sample + serialize (6000 samples/iteration at stack depths up to 400). RETAIN_EVERY is used to isolate the liveness-check path:
    • RETAIN_EVERY=1 → all 6000 tracked objects alive ⇒ 6000 lookup hits
    • RETAIN_EVERY=100000 → ~none alive ⇒ ~5999 lookup misses
    • RETAIN_EVERY=10 (default) → mixed, 600 live / 5400 dead

Environment: arm64 macOS laptop, no CPU pinning. Treat sub-1% differences as noise unless the ranges are disjoint.

Ruby 3.4.9 (rb_gc_mark_weak-based WeakMap), 3 reps

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+serialize spends ~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 timing heap_recorder_update directly, 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 at gen_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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

profiling Involves Datadog profiling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant