Skip to content

[NO-TICKET] Profiling: Reset per-thread state when starting profiler#5960

Merged
ivoanjo merged 31 commits into
masterfrom
ivoanjo/fix-leftover-state-between-profilers-try4
Jul 9, 2026
Merged

[NO-TICKET] Profiling: Reset per-thread state when starting profiler#5960
ivoanjo merged 31 commits into
masterfrom
ivoanjo/fix-leftover-state-between-profilers-try4

Conversation

@ivoanjo

@ivoanjo ivoanjo commented Jun 29, 2026

Copy link
Copy Markdown
Member

What does this PR do?

This PR introduces a new thread_context_collector_global_reset_per_thread_context function in the ThreadContext collector.

This function, as the name indicates, resets all of the per-thread context we keep, and is called by the CpuAndWallTimeWorker right before profiling starts so that we always start with a known-good blank slate, which fixes a few issues (discussed below) of stale state resulting in surprising behaviors.

This also allows us to simplify a few things in the code, as we get all of the below "naturally" from this reset:

  • We no longer need to allow for sampling_buffer resizes (it comes naturally from reallocating a new buffer at the start of profiling)
  • We no longer need to separately backfill per-thread context for threads that existed before the profiler loaded and we installed our thread_begin_tracepoint
  • We no longer need to reset the context after forking

Motivation:

Since #5816, we've been keeping the profiler's per-thread context directly attached to each Ruby thread.

This has a number of advantages:

  • We can keep as many fields as needed in the state and access them without the GVL
  • Ruby GC takes care of cleaning up the context from dead threads
  • We don't need to wait until the next cpu/wall sample to get the context on a thread and thus to start recording info about it, such as for allocation profiling or GVL events
  • Faster lookups!

Yet, because the per-thread context was being retained from profiler run to profiler run, we ran into a few rare corner cases:

  1. Stopping the profiler for a time period and restarting it again, would carry over state:
Datadog.configure { |c| c.profiling.enabled = true }
Datadog::Profiling.wait_until_running
sleep 5
Datadog.configure { |c| c.profiling.enabled = false }
sleep 5
Datadog.configure { |c| c.profiling.enabled = true } # <-- profiler #2
sleep 1

In #5926 (comment) you can see a screenshot of a 1 second profile with 6 seconds of cpu/wall-time blamed on a single sample, representing that gap when the profiler was stopped.

  1. It caused flakiness in the tests -- leading for instance to the skip in [NO-TICKET] Skip flaky profiling spec until a full fix #5929 .

Some of our tests had very strict assertions so we were playing whack-a-mole with "oh what if this one piece of leftover state was there between tests".

Change log entry

Yes. Profiling: Fix left-over state impacting samples when profiler gets stopped and started again

Additional Notes:

This is my third attempt at addressing this issue, and in particular it replaces the earlier #5926 .

How to test the change?

I've added code coverage for the new behavior, including a spec that asserts there's no carry-over state between executions of the profiler/CpuAndWallTimeWorker.

ivoanjo added 11 commits June 26, 2026 16:25
…avoid cross-profile leftover state

This helper is called when the `CpuAndWallTimeWorker` is about to start,
and resets all the per-thread state.

This avoids any cross-state contamination, and it's also not concurrent
with other things since the `CpuAndWallTimeWorker` has a built-in mechanism
to make sure a given instance is the only one running at a time.
The global reset now ensures a clean slate before each test (similar to
what happens in production with a new profiler run).
This ensures the need for a separate initialization step -- the global
reset takes care of it all.
This reverts commit 6b56cfa.

Now that `_native_global_reset_per_thread_context` runs before
every test-case, the leftover state that was causing the flakiness
should not happen anymore.

Out of curiosity, I could manually reproduce something very close
to the original flakiness with

```ruby
  describe "#sample_after_gvl_running" do
    before { skip_if_gvl_profiling_not_supported(self) }

    fcontext "if thread does not have per-thread context" do
      before { remove_per_thread_context_for(t1) }

      # it do
      #   expect(sample_after_gvl_running(t1)).to be false
      # end

      it 'weird test setup' do
        sleep_thread = Thread.new { sleep }
        sample # Creates context
        on_gvl_released(sleep_thread)
        sample # Waiting starts
        sample # Sets skip
        pp per_thread_context[sleep_thread]
      end

      it "does not sample the thread" do
        sample_after_gvl_running(t1)

        expect(samples).to be_empty
      end
    end
```

And running with `--order=defined` to make sure the specs ran in this
order.

(There was one very subtle detail here -- the
`was_skipped_at_last_sample` gets reset when the recorder is flushed so
the issue in particular that made the spec flaky might not happen in
practice in production since when we stop the profiler we flush the
recorder)
Now that we can rely on having a
`thread_context_collector_global_reset_per_thread_context` that will
right-size buffers, we no longer need to keep the buffer resizing logic.

I still left behind a buffer size check just in case some bug slips
through.
This avoids the benchmarks needing to care about this new detail.
@ivoanjo
ivoanjo requested review from a team as code owners June 29, 2026 11:05
@dd-octo-sts dd-octo-sts Bot added the profiling Involves Datadog profiling label Jun 29, 2026
@ivoanjo
ivoanjo requested a review from eregon June 29, 2026 11:06
@dd-octo-sts

dd-octo-sts Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Typing analysis

Note: Ignored files are excluded from the next sections.

Untyped methods

This PR introduces 2 partially typed methods, and clears 2 partially typed methods. It increases the percentage of typed methods from 65.99% to 66.0% (+0.01%).

Partially typed methods (+2-2)Introduced:
sig/datadog/profiling/collectors/thread_context.rbs:27
└── def self.for_testing: (
          recorder: Datadog::Profiling::StackRecorder,
          ?max_frames: ::Integer,
          ?tracer: Datadog::Tracing::Tracer?,
          ?endpoint_collection_enabled: bool,
          ?waiting_for_gvl_threshold_ns: ::Integer,
          ?otel_context_enabled: (::Symbol? | bool),
          ?native_filenames_enabled: bool,
          ?trigger_global_reset: bool,
          **untyped
        ) -> Datadog::Profiling::Collectors::ThreadContext
sig/datadog/profiling/collectors/thread_context.rbs:53
└── def safely_extract_context_key_from: (untyped tracer) -> ::Symbol?
Cleared:
sig/datadog/profiling/collectors/thread_context.rbs:27
└── def self.for_testing: (
          recorder: Datadog::Profiling::StackRecorder,
          ?max_frames: ::Integer,
          ?tracer: Datadog::Tracing::Tracer?,
          ?endpoint_collection_enabled: bool,
          ?waiting_for_gvl_threshold_ns: ::Integer,
          ?otel_context_enabled: (::Symbol? | bool),
          ?native_filenames_enabled: bool,
          **untyped
        ) -> Datadog::Profiling::Collectors::ThreadContext
sig/datadog/profiling/collectors/thread_context.rbs:48
└── def safely_extract_context_key_from: (untyped tracer) -> ::Symbol?

If you believe a method or an attribute is rightfully untyped or partially typed, you can add # untyped:accept on the line before the definition to remove it from the stats.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5d999114af

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread ext/datadog_profiling_native_extension/collectors_stack.c
@datadog-datadog-prod-us1-2

datadog-datadog-prod-us1-2 Bot commented Jun 29, 2026

Copy link
Copy Markdown

Tests

Fix all issues with BitsAI

⚠️ Warnings

❄️ 1 New flaky test detected

Datadog::Profiling::Collectors::ThreadContext#thread_context_collector_reset_all_per_thread_contexts resets every existing per-thread context from rspec   View in Datadog
undefined method \`_native_on_gvl_released&#39; for Datadog::Profiling::Collectors::ThreadContext::Testing:Module

    described_class::Testing._native_on_gvl_released(thread)
                            ^^^^^^^^^^^^^^^^^^^^^^^^
Did you mean?  _native_on_gc_start

Failure/Error: described_class::Testing._native_on_gvl_released(thread)

NoMethodError:
  undefined method \`_native_on_gvl_released&#39; for Datadog::Profiling::Collectors::ThreadContext::Testing:Module
...

New test introduced in this PR is flaky.

View in Flaky Test Management

ℹ️ Info

No other issues found (see more)

🧪 All tests passed

🔄 Datadog auto-retried 1 job - 1 passed on retry View in Datadog

🎯 Code Coverage (details)
Patch Coverage: 100.00%
Overall Coverage: 90.04% (+0.00%)

Useful? React with 👍 / 👎

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

…ing logic

Codex correctly pointed out:

> When `max_frames` changes during `Datadog.configure` reconfiguration,
`> replace_components!` builds the new components before calling
> `old.shutdown!`, and constructing the new `ThreadContext` updates the
> global `latest_max_frames` while the old profiler can still be sampling.
> Any thread created in that window gets a buffer sized for the new
> collector, so this hard raise makes the still-running old worker report
> a profiler failure instead of resizing/capping as it did before.

We fix this by setting the `latest_max_frames` not at `ThreadContext`
creation, but along with the global reset when the `ThreadContext` is
**about to be used**.

This avoids the issue above.
@pr-commenter

pr-commenter Bot commented Jun 29, 2026

Copy link
Copy Markdown

Benchmarks

Benchmark execution time: 2026-07-09 13:13:00

Comparing candidate commit 4404932 in PR branch ivoanjo/fix-leftover-state-between-profilers-try4 with baseline commit cf99651 in branch master.

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 ----------------------------------'

@ivoanjo
ivoanjo marked this pull request as draft June 29, 2026 14:57
@ivoanjo

ivoanjo commented Jun 29, 2026

Copy link
Copy Markdown
Member Author

Marking as draft as my last changes were not correct + I uncovered a latent bug elsewhere

ivoanjo added 3 commits June 30, 2026 09:56
I saw this blowing up in CI due to some other errors on the branch,
and in practice raising exceptions here is never correct and it was
good that Ruby complained.
@ivoanjo

ivoanjo commented Jun 30, 2026

Copy link
Copy Markdown
Member Author

Ok, I believe this is finally in good shape for review!

@ivoanjo
ivoanjo marked this pull request as ready for review June 30, 2026 13:23
Comment thread ext/datadog_profiling_native_extension/collectors_cpu_and_wall_time_worker.c Outdated

@eregon eregon left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Great stuff, this feels much cleaner and less brittle than explicitly clearing contexts, because we global reset only at known "safe points" i.e. when the profiler is stopped and all hooks are reliably disabled

Comment thread ext/datadog_profiling_native_extension/collectors_cpu_and_wall_time_worker.c Outdated
Comment thread ext/datadog_profiling_native_extension/collectors_stack.c Outdated
Comment thread ext/datadog_profiling_native_extension/collectors_thread_context.c
Comment thread ext/datadog_profiling_native_extension/collectors_thread_context.c Outdated
Comment thread ext/datadog_profiling_native_extension/collectors_thread_context.c
Comment thread spec/datadog/profiling/collectors/cpu_and_wall_time_worker_spec.rb
Comment thread spec/datadog/profiling/collectors/cpu_and_wall_time_worker_spec.rb
Comment thread spec/datadog/profiling/collectors/thread_context_spec.rb Outdated
Comment thread spec/datadog/profiling/collectors/thread_context_spec.rb Outdated
Comment thread spec/datadog/profiling/collectors/thread_context_spec.rb Outdated
Comment thread ext/datadog_profiling_native_extension/collectors_thread_context.c Outdated
Comment thread ext/datadog_profiling_native_extension/collectors_thread_context.c
ivoanjo added 2 commits July 7, 2026 11:22
Even though these methods don't get used in practice on old Rubies
(since they have no GVL instrumentation API hooks), it's useful to
have these outside of `NO_GVL_INSTRUMENTATION` for the
`Datadog::Profiling::Collectors::ThreadContext#thread_context_collector_reset_all_per_thread_contexts`
"resets every existing per-thread context" test, and it seems harmess to
always build it.
Comment thread lib/datadog/profiling/collectors/thread_context.rb
Comment thread spec/datadog/profiling/collectors/thread_context_spec.rb
Comment thread spec/datadog/profiling/collectors/thread_context_spec.rb
…er_thread_contexts` specs

This way we ensure that there's a non-zero state on the thread for
all other cases (e.g. asserts that a no-op
`global_reset_per_thread_context` won't trivially pass).
@ivoanjo
ivoanjo force-pushed the ivoanjo/fix-leftover-state-between-profilers-try4 branch from 59b56f4 to b4021d4 Compare July 7, 2026 13:01
ivoanjo added 2 commits July 9, 2026 13:11
As pointed out very correctly in PR review, `free` && `ruby_xfree` are
happy to no-op a NULL so let's just clean this up instead of writing
a treaty on conditions where it might not have been initialized.
@ivoanjo
ivoanjo merged commit c1b6e95 into master Jul 9, 2026
595 of 598 checks passed
@ivoanjo
ivoanjo deleted the ivoanjo/fix-leftover-state-between-profilers-try4 branch July 9, 2026 13:36
@dd-octo-sts dd-octo-sts Bot added this to the 2.38.0 milestone Jul 9, 2026
ivoanjo added a commit that referenced this pull request Jul 10, 2026
…try 2

**What does this PR do?**

This PR is a second attempt at
#6006 ; the first time
got reverted in #6012 as
we were seeing flaky failures.

From DataDog/ruby-guild#314:

```
W, [2026-07-08T16:58:22.863694 #11835]  WARN -- datadog: [datadog] CpuAndWallTimeWorker thread error. Operation: "rescued_sample_from_postponed_job" Cause: RuntimeError: BUG: Unexpected sample during GC (thread_id=11835 (10200), gc_tracking.cpu_time_at_start_ns=2802579244, gc_tracking.wall_time_at_start_ns=729604100602, monotonic_wall_time_now_ns=729890337067) Location: /__w/dd-trace-rb/dd-trace-rb/lib/datadog/profiling/collectors/idle_sampling_helper.rb:42:in 'Datadog::Profiling::Collectors::IdleSamplingHelper._native_idle_sampling_loop'
W, [2026-07-08T16:58:22.863821 #11835]  WARN -- datadog: [datadog] Failed to send telemetry before components initialization or within components lifecycle
    records garbage collection cycles (FAILED - 3)
    marks the new thread as fork-safe
    does not create a second thread if start is called again
    with allocation profiling enabled
/__w/dd-trace-rb/dd-trace-rb/spec/datadog/profiling/collectors/cpu_and_wall_time_worker_spec.rb:43: [BUG] Datadog Ruby profiler detected callback nested inside sample. Please report this at https://github.com/datadog/dd-trace-rb/blob/master/CONTRIBUTING.md#found-a-bug
ruby 4.0.1 (2026-01-13 revision e04267a14b) +PRISM [x86_64-linux]
```

(The "callback nested inside sample" happens because some of our
tests have strict assertions that things like object allocations
can't happen, and they were failing when trying to raise that
`RunntimeError: BUG: Unexpected sample during GC` as well)

The problem we were seeing was due to the `#on_gc_start` specs
never clearing the "thread is in GC" state and thus this state was
leaking to other tests which then would trigger the assertion
we added.

The fix is to make sure we don't leave such leftover state; in the
future #5960 is a more
holistic solution to this whole "leftover state between profiler
instances" problem.

I recommend reviewing this PR commit-by-commit.

**Motivation:**

The original cleanup is useful so we definitely want to keep it!

**Additional Notes:**

```
bundle exec rspec './spec/datadog/profiling/collectors/cpu_and_wall_time_worker_spec.rb[1:2:12:1,1:2:12:2,1:2:13,1:2:15:1,1:2:16,1:2:18:1,1:2:18:2:1,1:2:20:1,1:7:1]' './spec/datadog/profiling/collectors/thread_context_spec.rb[1:3:2:1]' --seed 32922
```

is one way of reproducing the issue.

**How to test the change?**

We should see a green CI and no flakiness.
ivoanjo added a commit that referenced this pull request Jul 10, 2026
Now that #5960 landed, we don't need these hooks anymore.

I've confirmed that #5960 still fixes the issue:

```
bundle exec rspec './spec/datadog/profiling/collectors/cpu_and_wall_time_worker_spec.rb[1:2:12:1,1:2:12:2,1:2:13,1:2:15:1,1:2:16,1:2:18:1,1:2:18:2:1,1:2:20:1,1:7:1]' './spec/datadog/profiling/collectors/thread_context_spec.rb[1:3:2:1]' --seed 32922
```

doesn't break but commenting out
`thread_context_collector_reset_all_per_thread_contexts(state->thread_context_collector_instance);`
inside `_native_sampling_loop` in
`collectors_cpu_and_wall_time_worker.c` does trigger the issue again,
confirming that it's the reset that's now taking care of fixing the
issue as expected.
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.

2 participants