Skip to content

perf(profiling): reuse exporter across upload cycles - #17584

Closed
r1viollet wants to merge 5 commits into
mainfrom
r1viollet/reuse-profiler-exporter
Closed

perf(profiling): reuse exporter across upload cycles#17584
r1viollet wants to merge 5 commits into
mainfrom
r1viollet/reuse-profiler-exporter

Conversation

@r1viollet

Copy link
Copy Markdown
Contributor

Description

The profiler was creating a new ddog_prof_ProfileExporter on every upload cycle (every ~60s), then immediately dropping it. Each creation allocates TLS connectors, tokio runtime state, and HTTP client resources in Rust — ~10-15 native allocations per cycle that contribute to memory fragmentation.

This follows the pattern used by ddprof, which creates the exporter once at startup and reuses it for all subsequent uploads.

Changes:

  • Move exporter ownership to ProfilerState (lazy creation on first upload)
  • UploaderBuilder::build() reuses the cached exporter
  • Uploader now borrows the exporter by reference instead of owning it
  • Drop exporter in prefork() (not postfork_child) to avoid calling Rust drop in a forked process where tokio threads are dead
  • Drop exporter in cleanup() for clean shutdown

Testing

This is heavily vibe coded, so I would appreciate if someone from the python team could carefully review these code paths considering how dangerous the forking scenarios can be.

Risks

Additional Notes

@cit-pr-commenter-54b7da

cit-pr-commenter-54b7da Bot commented Apr 16, 2026

Copy link
Copy Markdown

Codeowners resolved as

ddtrace/internal/datadog/profiling/dd_wrapper/src/uploader_builder.cpp  @DataDog/profiling-python

@datadog-official

datadog-official Bot commented Apr 16, 2026

Copy link
Copy Markdown
Contributor

Pipelines  Tests

Fix all issues with BitsAI

⚠️ Warnings

🚦 11 Pipeline jobs failed

DataDog/apm-reliability/dd-trace-py | build linux: [amd64, cp315-cp315, v113741238-d2b8243-manylinux2014_x86_64]   View in Datadog   GitLab

🔧 Fix in code (Fix with Cursor). NotImplementedError: This version of CPython is not supported yet

DataDog/apm-reliability/dd-trace-py | build linux: [arm64, cp315-cp315, v113741589-d2b8243-musllinux_1_2_aarch64]   View in Datadog   GitLab

🔧 Fix in code (Fix with Cursor). NotImplementedError: This version of CPython is not supported yet

System Tests | tracer-release / End-to-end #5 / uwsgi-poc 5   View in Datadog   GitHub Actions

🔧 Fix in code (Fix with Cursor). 1 failed test. Assertion Error: no extra_services contains extraVegetables at tests/remote_config/test_remote_configuration.py:267

View all 11 failed jobs.

ℹ️ Info

No other issues found (see more)

🧪 All tests passed
❄️ No new flaky tests detected

Useful? React with 👍 / 👎

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

Comment on lines +174 to +177
// Drop the cached exporter before fork so the child doesn't inherit stale
// Rust state (tokio runtime, TLS, connection pool). Dropping here — in the
// parent, under lock, with no upload in flight — is safe. Both parent and
// child will lazily recreate the exporter on the next upload.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Doing some research allowed me to find that the original reason for not reusing the Exporter was exactly this: runtime_id changes as well as process_id (but this last one was introduced way later).

If we properly reinitialise the Exporter post-fork in the child, I guess we should be fine...

@r1viollet
r1viollet force-pushed the r1viollet/reuse-profiler-exporter branch from c1a0ac4 to 7e769f9 Compare May 13, 2026 16:39
@r1viollet r1viollet added the changelog/no-changelog A changelog entry is not required for this PR. label May 13, 2026
@r1viollet
r1viollet marked this pull request as ready for review May 13, 2026 16:41
@r1viollet
r1viollet requested a review from a team as a code owner May 13, 2026 16:41
@r1viollet
r1viollet requested a review from vlad-scherbich May 13, 2026 16:41
@r1viollet

Copy link
Copy Markdown
Contributor Author

Now that we have reached a more stable state, I'll consider pushing these changes

@r1viollet

Copy link
Copy Markdown
Contributor Author

@codex review

@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: 7e769f9c53

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +141 to +142
if (state.cached_exporter.inner != nullptr) {
return std::nullopt;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Rebuild exporter when upload configuration changes

When the tracer endpoint or per-upload tags change after the first upload, this early return keeps using the exporter that was built from the old ProfilerState snapshot. _ddup.upload() still calls ddup_set_runtime_id(), ddup_set_process_id(), ddup_config_user_tag("process_type", ...), and ddup_config_url() immediately before every ddup_upload(), so applications that reconfigure the tracer URL or whose runtime/process metadata changes without a fork will keep sending profiles with stale destination/tags until the next fork or process exit. Consider invalidating cached_exporter from the relevant set_* methods or comparing the current config before reusing it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I'm not sure how valid that is; clearly it would be the "most correct thing to do", but I don't know if there's a simple way to check whether the configuration has changed since the process start.

What I can think of:

  • ✔️ I don't think the runtime ID should change
  • ✔️ I don't think the PID should change -- we reset it post-fork so as long as we drop the Exporter post-fork and either re-construct it after setting the PID (or just lazily, at next upload) it should be fine
  • ✔️ I don't think changing the upload URL mid-application lifetime is a real pattern used by anyone
  • ⚠️ I do think the user tags thing is a valid concern... We've discussed "manual usage of the Profiler class" in the past and initially thought we should be preventing it. In the meantime, we've (re-)discovered that it was a publicly advertised feature (in our docs) so we can't just kill it. Thing is, manual Profiler class usage is useful among others in cases where the user wants to set specific tags based on application-level data (e.g. the Delancie Worker sets the Task name tag before starting its Profiler). In the current form, I think this may or may not break (depending on whether we are lucky, whether the parent process had an active Profiler, etc.)

So I would say:

  • Either make sure we reconstruct the Exporter if user tags change (bit of a hassle and probably not-great code as we'll need additional variables to keep track of past state and compare, for sure)
  • Either deliberately possibly break this -- but not always, again it's a matter of luck -- but given that we postponed the deprecation of manual Profiler usage to the next major, I wouldn't do that -- it would look too accidental (+ we still have some internal use for it at the moment...)

@KowalskiThomas KowalskiThomas left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Sorry I took so long to review!

I think overall the changes make sense (despite my ten comments) but to be sure we're not missing anything I'd recommend testing on dogweb/dd-source staging or at the very least smoke test apps for a week or so.
We have docs on how to do that if you need it let me know!

Comment on lines +95 to +102
// Cached profile exporter, reused across upload cycles. Each construction
// allocates a tokio runtime, TLS connector and HTTP client on the Rust side
// (~10-15 native allocations), so we build it once and keep it.
// Lifetime: created lazily on first upload, dropped in prefork() (parent side,
// while tokio threads are still alive) and in cleanup(). Always accessed under
// upload_lock — except in cleanup(), which runs single-threaded at exit.
// Config snapshot: baked from ProfilerState at first build; later set_* calls
// do not retroactively update it.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
// Cached profile exporter, reused across upload cycles. Each construction
// allocates a tokio runtime, TLS connector and HTTP client on the Rust side
// (~10-15 native allocations), so we build it once and keep it.
// Lifetime: created lazily on first upload, dropped in prefork() (parent side,
// while tokio threads are still alive) and in cleanup(). Always accessed under
// upload_lock — except in cleanup(), which runs single-threaded at exit.
// Config snapshot: baked from ProfilerState at first build; later set_* calls
// do not retroactively update it.
// Cached profile exporter, reused across upload cycles. Each construction
// allocates a tokio runtime, TLS connector and HTTP client on the Rust side
// (~10-15 native allocations), so we build it once and keep it.
// Lifetime: created lazily on first upload, dropped in prefork (parent side,
// while tokio threads are still alive) and in cleanup. Always accessed under
// upload_lock — except in cleanup, which runs single-threaded at exit.
// Config snapshot: baked from ProfilerState at first build; later set_* calls
// do not retroactively update it.

Comment on lines +141 to +142
if (state.cached_exporter.inner != nullptr) {
return std::nullopt;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I'm not sure how valid that is; clearly it would be the "most correct thing to do", but I don't know if there's a simple way to check whether the configuration has changed since the process start.

What I can think of:

  • ✔️ I don't think the runtime ID should change
  • ✔️ I don't think the PID should change -- we reset it post-fork so as long as we drop the Exporter post-fork and either re-construct it after setting the PID (or just lazily, at next upload) it should be fine
  • ✔️ I don't think changing the upload URL mid-application lifetime is a real pattern used by anyone
  • ⚠️ I do think the user tags thing is a valid concern... We've discussed "manual usage of the Profiler class" in the past and initially thought we should be preventing it. In the meantime, we've (re-)discovered that it was a publicly advertised feature (in our docs) so we can't just kill it. Thing is, manual Profiler class usage is useful among others in cases where the user wants to set specific tags based on application-level data (e.g. the Delancie Worker sets the Task name tag before starting its Profiler). In the current form, I think this may or may not break (depending on whether we are lucky, whether the parent process had an active Profiler, etc.)

So I would say:

  • Either make sure we reconstruct the Exporter if user tags change (bit of a hassle and probably not-great code as we'll need additional variables to keep track of past state and compare, for sure)
  • Either deliberately possibly break this -- but not always, again it's a matter of luck -- but given that we postponed the deprecation of manual Profiler usage to the next major, I wouldn't do that -- it would look too accidental (+ we still have some internal use for it at the moment...)


// Disable copy constructor and copy assignment operator to avoid double-free
// of ddog_exporter
// Disable copy to avoid double-free of encoded_profile.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Seems like some comments have been changed but I'm not sure whether they should have been 🤨

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

To me this is relevant change

Comment on lines +118 to +119
// Caller holds upload_lock, so this access is serialized with prefork/cleanup
// (the only places that drop the exporter).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
// Caller holds upload_lock, so this access is serialized with prefork/cleanup
// (the only places that drop the exporter).
// Caller holds upload_lock, so this access is serialized with prefork/cleanup
// (the only places that drop the exporter) and other uploads.

I think there is theoretically a case where we could try to start two parallel uploads, but the upload lock prevents it. Might be worth adding here.

// The exporter is owned by ProfilerState and reused across uploads.
// Caller holds upload_lock, so this access is serialized with prefork/cleanup
// (the only places that drop the exporter).
auto& cached_exporter = ProfilerState::get().cached_exporter;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Do we need to specify it's cached? Why not just exporter?

}
ddog_CancellationToken_drop(&new_cancel_clone_for_request);
ddog_prof_Exporter_drop(&ddog_exporter);
// cached_exporter intentionally NOT dropped: it is reused across uploads.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think we can just remove that comment, outside the context of that PR it doesn't need to exist.

Datadog::UploaderBuilder::build()
namespace {

// Create the cached exporter if it does not already exist.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
// Create the cached exporter if it does not already exist.
// Create the exporter if it does not already exist.

Here as well I don't think we should specify "cached". If the model we have is "we have one Exporter object that we keep forever" then there's no need to name it "cached Exporter" just like we don't name our sampling thread "cached sampling thread".

{ ExportTagKey::profiler_version, state.profiler_version },
{ ExportTagKey::process_id, state.process_id }
const std::vector<std::pair<Datadog::ExportTagKey, std::string_view>> tag_data = {
{ Datadog::ExportTagKey::dd_env, state.dd_env },

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No strong opinion, but would a using namespace Datadog in the anonymous namespace make sense here? I don't see a reason not to.

to_slice(family),
Datadog::to_slice("dd-trace-py"),
Datadog::to_slice(state.profiler_version),
Datadog::to_slice(g_language_name),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Why did we change from family to language name? Is there a difference?


// Lazily create the exporter on first build. Subsequent builds reuse it.
// The caller (ddup_upload) holds upload_lock, so this is serialized with
// prefork() / cleanup() — the only places that drop the exporter.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
// prefork() / cleanup() — the only places that drop the exporter.
// prefork / cleanup — the only places that drop the exporter.

@r1viollet
r1viollet marked this pull request as draft May 22, 2026 14:30
r1viollet and others added 5 commits May 29, 2026 10:19
The profiler builds a new ddog_prof_ProfileExporter on every upload
(~60s). Each construction allocates a tokio runtime, TLS connector and
HTTP client on the Rust side — about 10–15 native allocations per cycle
that the allocator never gets to coalesce.

Cache the exporter in ProfilerState and reuse it across uploads,
matching ddprof's pattern. Lifecycle:

- Lazily created on first build, under upload_lock (held by ddup_upload).
- Dropped in prefork() while still in the parent (under upload_lock,
  with no upload in flight) — dropping it post-fork in the child is
  unsafe because the tokio worker threads do not survive fork.
- Dropped in cleanup() at exit.
- Recreated lazily on the next upload in both parent and child.

Uploader no longer owns the exporter; it reaches the cached one through
ProfilerState in upload_unlocked() instead. This keeps Uploader's move
semantics intact and confines the exporter's lifecycle to ProfilerState.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Address review feedback:

- User-defined tags (DD_TAGS, dd_trace_api.profiling.tag, the per-task tag
  pattern used by manual Profiler usage) can change between uploads. Baking
  them into a long-lived exporter risks sending stale tags after such a
  change. Move them out of the exporter and pass them per-send via
  optional_additional_tags, which libdatadog exposes for exactly this case.
  The exporter still bakes the static identity tags (env, service, version,
  language, runtime, runtime_id, runtime_version, profiler_version,
  process_id) and the endpoint (URL, timeout). runtime_id and process_id
  remain correct after fork because we drop the exporter in prefork.

- Rename ProfilerState::cached_exporter to ProfilerState::exporter; drop
  "cached" from associated comments and the local in upload_unlocked.

- Lift the using-directive into the anonymous namespace in
  uploader_builder.cpp to remove the Datadog:: prefix noise.

- Comment polish: no parens on function references (prefork, cleanup),
  expand the upload_unlocked comment to mention upload_lock also
  serializing parallel uploads, drop the trailing
  "intentionally NOT dropped" comment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…language distinction

- Introduce g_family_name constant and use it for the exporter's family slot,
  separate from g_language_name. They coincide for this profiler, but the
  concepts are different (an eBPF profiler can run in family `native` while
  sampling `python` code).
- Replace the literal "dd-trace-py" string with the existing g_library_name
  constant for the library-name slot.
- New tag_rotation_program.py driver: starts a Profiler, rotates a per-upload
  tag (phase=setup/warmup/production), forces an upload each cycle.
- New test_per_upload_tags.py: runs the driver under an in-process
  ThreadingHTTPServer mock, captures the multipart bodies, asserts each
  cycle's body contains the expected `phase:<value>` tag, AND asserts the
  previous cycle's tag does NOT appear in a later cycle's body. The negative
  assertion is the regression guard — if user tags were baked back into the
  cached exporter, the first cycle's tag would leak into all subsequent
  cycles' bodies.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…driver

Profiler.start() takes no arguments in current main; the kwarg was a
leftover from earlier iterations. Verified end-to-end against a real
trace-agent on linux and via the mock-agent test_per_upload_tags.py
(3 cycles, each with its own phase:<value> tag, no leakage).
…follow-up

Reviewer flagged that UploaderBuilder::build() reaches into the
ProfilerState singleton instead of taking what it needs as an argument.
That pattern predates this PR (every set_* method + build_user_tag_vec
already does it), and untangling it cleanly means threading an explicit
config struct through the whole UploaderBuilder API surface. That is its
own refactor; punting via an AIDEV-NOTE so the follow-up has a clear
anchor.
@r1viollet
r1viollet force-pushed the r1viollet/reuse-profiler-exporter branch from 81face1 to 72881f3 Compare May 29, 2026 10:24
@github-actions github-actions Bot added the stale label Jun 29, 2026
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

This pull request has been automatically closed after a period of inactivity.
After this much time, it will likely be easier to open a new pull request with the
same changes than to update this one from the base branch. Please comment or reopen
if you think this pull request was closed in error.

@r1viollet

Copy link
Copy Markdown
Contributor Author

Superseded by #19330 — same 5 commits, rebased on current main, no content changes. This PR was auto-closed by the stale-PR bot on 2026-07-01 while awaiting staging validation; gh pr reopen was blocked so I opened a fresh PR.

@KowalskiThomas

Copy link
Copy Markdown
Collaborator

@r1viollet can we close the other PR and reopen this one to keep the review history etc. instead of everything vanishing in the new PR?

@r1viollet

Copy link
Copy Markdown
Contributor Author

@KowalskiThomas it does not look like it. Reopen button is not available.

@KowalskiThomas

Copy link
Copy Markdown
Collaborator

@r1viollet I believe that's because the other PR is open, if you close it you'll be able to re-open this one I think

@r1viollet

Copy link
Copy Markdown
Contributor Author

No, I tried this. Reopen button is still not complying (because the branch moved).

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

Labels

changelog/no-changelog A changelog entry is not required for this PR. stale

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants