perf(profiling): bound task stack collection - #19085
Draft
taegyunkim wants to merge 29 commits into
Draft
taegyunkim wants to merge 29 commits into
taegyunkim wants to merge 29 commits into
Conversation
Codeowners resolved asResolved from the full PR diff against |
Circular import analysis
|
|
✅ All CI checks and tests passed. 🎉 All green!🧪 All tests passed 🔗 Commit SHA: 7f050d0 | Docs | View more details | Give us feedback! |
taegyunkim
force-pushed
the
taegyunkim/prof-14213-propagate-max-frames
branch
from
July 15, 2026 20:02
32b532a to
ecdbdab
Compare
taegyunkim
force-pushed
the
taegyunkim/prof-14213-task-stack-frame-budget
branch
2 times, most recently
from
July 16, 2026 01:00
3b50116 to
e3d2e8a
Compare
gh-worker-dd-mergequeue-cf854d Bot
pushed a commit
that referenced
this pull request
Jul 16, 2026
## Description Aligns profiling frame-limit configuration with the backend's [600-location limit](https://datadoghq.atlassian.net/wiki/spaces/PROF/pages/5307007274/Profiling+Libraries+Frame+Truncation+Behaviors). - raises the native exporter cap from 512 to 599 captured frames - reserves the 600th backend location for the synthetic `<N frames omitted>` indicator on truncated stacks - clamps `DD_PROFILING_MAX_FRAMES` to 599 in `ddtrace.internal.settings.profiling` - preserves configured values from 513 through 599 instead of reducing them to 512 in the native exporter - ensures collectors receive the same effective configured limit JIRA: [PROF-14213](https://datadoghq.atlassian.net/browse/PROF-14213?atlOrigin=eyJpIjoiNWFmMTNkMDg2MmJjNDVlZTlkN2JlNGZkMjg3ZWZiYzEiLCJwIjoiaiJ9) ## Testing - Added configuration tests covering the default, values below the maximum captured-frame limit, the exact 599-frame limit, and values at or above the 600-location backend limit. - Ran `TestMaxFramesConfig` on Python 3.9, all 6 tests passed. The editable install also rebuilt the native extension successfully. - Completed a clean Python 3.14 native build for the initial change. - Ran `scripts/lint checks`, targeted formatting, spelling, and `git diff --check`. Broad lint checks reported only existing repository warnings. ## Risks Configurations between 513 and 599 can now retain more frames than before. This matches the backend contract but can slightly increase per-sample work for users who explicitly configure values in that range. Values of 600 and above are now clamped to 599 at configuration load instead of reaching individual collectors unchanged. This leaves one location for the omitted-frame indicator so truncated samples remain within the backend's 600-location cap. ## Additional Notes This PR is the base of stacked #19073 and #19085, which propagate the limit into Echion and bound task-aware stack collection. [PROF-14213]: https://datadoghq.atlassian.net/browse/PROF-14213?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiaiJ9 Co-authored-by: taegyun.kim <taegyun.kim@datadoghq.com>
taegyunkim
force-pushed
the
taegyunkim/prof-14213-propagate-max-frames
branch
from
July 16, 2026 21:17
67ae55e to
c0a7c8f
Compare
taegyunkim
force-pushed
the
taegyunkim/prof-14213-task-stack-frame-budget
branch
from
July 16, 2026 21:17
b2e94b5 to
29fa643
Compare
taegyunkim
force-pushed
the
taegyunkim/prof-14213-propagate-max-frames
branch
from
July 16, 2026 21:45
c0a7c8f to
dd6bece
Compare
taegyunkim
force-pushed
the
taegyunkim/prof-14213-task-stack-frame-budget
branch
from
July 16, 2026 21:45
29fa643 to
679beb6
Compare
…-propagate-max-frames # Conflicts: # ddtrace/internal/datadog/profiling/stack/src/echion/threads.cc
…-propagate-max-frames
…-propagate-max-frames # Conflicts: # ddtrace/internal/datadog/profiling/stack/__init__.pyi # ddtrace/internal/datadog/profiling/stack/_stack.pyi # ddtrace/internal/datadog/profiling/stack/echion/echion/echion_sampler.h # ddtrace/internal/datadog/profiling/stack/src/stack.cpp # ddtrace/profiling/collector/stack.py
…max-frames' into taegyunkim/prof-14213-propagate-max-frames
taegyunkim
force-pushed
the
taegyunkim/prof-14213-task-stack-frame-budget
branch
from
August 19, 2026 19:08
9aa8d6c to
811e3b9
Compare
Dependency direction analysis
|
…max-frames' into taegyunkim/prof-14213-task-stack-frame-budget # Conflicts: # ddtrace/internal/datadog/profiling/stack/fuzz/fuzz_echion_stacks.cpp # ddtrace/internal/datadog/profiling/stack/fuzz/fuzz_echion_task_unwind.cpp # ddtrace/internal/datadog/profiling/stack/include/stack_renderer.hpp # ddtrace/internal/datadog/profiling/stack/src/echion/stacks.cc # ddtrace/internal/datadog/profiling/stack/src/echion/threads.cc # ddtrace/internal/datadog/profiling/stack/src/stack_renderer.cpp # tests/profiling/collector/test_stack.py
gh-worker-dd-mergequeue-cf854d Bot
pushed a commit
that referenced
this pull request
Sep 14, 2026
…19073) ## Description `DD_PROFILING_MAX_FRAMES` limits the number of frames reported in profiles, but the native Echion sampler previously walked plain Python thread stacks up to its 2,048-frame safety ceiling. With the default 64-frame limit, this could collect and materialize many frames that the exporter immediately discarded. This PR propagates `StackCollector.nframes` into the native sampler and stops plain thread stack collection at that limit. It: - adds the internal native `set_max_frames()` entry point, preserving per-collector overrides and the existing zero/default behavior - rejects frame-limit updates while the native sampler is starting, running, or finishing - returns `UnwindResult` with explicit `Unknown`, `NotTruncated`, or `Truncated` status, and uses `UnwindResult::Unknown()` for unknown initialization - performs bounded lookahead after reaching the limit and calls `mark_truncated()` only after finding another reportable Python frame, avoiding false markers for terminal C/interpreter frames - documents that disabled detection always returns `Unknown`, while enabled detection can also remain unknown after a read failure, cycle, or exhausted probe budget - propagates an initial C-frame copy failure through `Result<UnwindResult>` and `ThreadInfo::unwind()` to abort the sample before task/greenlet discovery or rendering - leaves the existing 1,024-entry Echion frame cache unchanged Asyncio-loop threads retain the existing 2,048-frame discovery depth because task stitching needs deeper physical frames to find the event-loop boundary and determine overlap. Greenlet logical stacks also retain their existing 2,048-frame discovery depth so this PR does not change parent-segment stitching. The configured reporting limit still applies to rendered profiles. Follow-up #19085 changes asyncio materialization separately. Merged the latest `origin/main` at `33e9b69e96`. ## Testing - Rebuilt native extensions and native test binaries for CPython 3.14.5 and CPython 3.12.13 in a source-identical temporary checkout. Used the repository's editable-wheel build procedure and `scripts/run-tests` in its local CI execution mode because the Docker host filesystem was full. - Python 3.14 stack/GC tests: **75 passed, 6 skipped**. - Python 3.12 stack/GC tests: **75 passed, 6 skipped**. - Native sampling-cycle and unwind tests: **6 passed on each Python version**, including disabled detection, bounded lookahead through ignored frames, and the Python 3.12 initial C-frame copy-error path. - Removed the native-frame disable override from the truncation tests. They now leave exporter headroom for injected native frames and separately assert the Python frame chain and conservative omission marker. - Full `scripts/lint checks`, C/C++ formatting, Reno lint, and `git diff --check origin/main` passed. Existing repository-wide lint warnings remain. - Five targeted Python 3.12 frame-limit tests passed. - Added a direct Python 3.12 native test that asserts `frames_added == 2` and `TruncationStatus::Truncated`; all **23 standalone native CTests passed**. - Strengthened the fork regression so the child samples a deep stack after restart and verifies its one-frame limit and omission marker. - Set the existing gevent stack test to `nframes=1` while retaining exporter headroom; it still requires at least three recursive `_fib` frames, proving greenlet discovery retains its existing depth. - Targeted Python 3.12 results after these changes: standard profiling **5 passed, 1 skipped**; gevent profiling **6 passed**. - Validated this change as part of integration wheel `408cb68fc3a99557dff4cf595fed9f8321dc0ea2` in the isolated Python 3.13 Rapid Test Drive `prof-14213-max-frames-v4`. The service reached `ready`, its core CPU, memory, and wait workload checks returned HTTP 200, and the profiling backend received Python, native, and eBPF profiles. ### CPU performance Measured with dd-trace-doe at commit `b68974823dacb76ef7728f64bbdc0acab79e1fc9`. The experiment compared this PR with benchmark baseline `8e80a8b4e27d8009521ddac3a51c06329b175000`, which was the exact `main` merge base when the experiment ran. It used Python 3.13, Flask `gthread`, four workers, 40 closed-loop clients, one-second sleeps inside generated stacks, tracing disabled, profiling enabled, and 60-second runs. Each version and stack depth was repeated three times. The table reports the mean of DoE's top-level CPU metric, `cpu / load_wall * 100`, where `cpu` includes measured-load and shutdown/flush CPU: | Stack depth | Baseline CPU | PR CPU | Difference | Relative change | | ---: | ---: | ---: | ---: | ---: | | 64 | 12.1% | 11.4% | -0.7 pp | -6.0% | | 256 | 16.6% | 11.5% | -5.1 pp | -30.7% | | 768 | 33.1% | 13.1% | -20.0 pp | -60.4% | All 18 runs completed with zero request errors and produced 180 profile payloads. DoE classifies three repetitions as low confidence, so these results demonstrate the expected depth-dependent direction and magnitude rather than a general production-overhead claim. Per-run CPU ranges were 11.9-12.3% versus 11.0-11.6% at depth 64, 16.3-16.9% versus 10.9-12.1% at depth 256, and 32.1-34.0% versus 12.6-13.7% at depth 768. ### CI follow-up - Renamed the inconclusive truncation state from `Unchecked` to `Unknown`, which covers both skipped detection and detection that could not reach a conclusion. - Explicitly discarded the new `Result<void>` return value in the remaining thread-unwind fuzz harness, fixing clang-tidy's `nodiscard` diagnostic. - Kept the release-note wording change in a separate commit from the main merge. Reno rejects the combined add/modify history produced when that note is edited inside the merge itself. - Re-ran the full profiling clang-tidy wrapper with LLVM 18, full `scripts/lint checks`, and the six native sampling-cycle/unwind tests. - `scripts/docs/build.sh` passed Reno lint and the warnings-as-errors HTML build using `READTHEDOCS=1`. The local Sphinx spelling stage was skipped because the machine lacks its dictionary setup; repository-wide codespell checks passed. ## Risks Plain thread stacks stop unwinding at the configured limit. The omitted-frame count is deliberately conservative: an early-truncation marker records at least one deeper reportable Python frame, rather than walking and counting the entire omitted portion. Asyncio discovery retains its existing safety ceiling. An unreadable initial C frame now aborts the sample rather than continuing without a physical stack. No public API changes. ## Additional Notes - JIRA: PROF-14213 - Asyncio follow-up: #19085 - Release note: `releasenotes/notes/profiling-propagate-max-frames-f7a718abdfb99c4c.yaml`: "Stack collection now honors configured frame limits, reducing profiler CPU and memory overhead for deep Python call stacks." Co-authored-by: taegyun.kim <taegyun.kim@datadoghq.com>
Base automatically changed from
taegyunkim/prof-14213-propagate-max-frames
to
main
September 14, 2026 19:31
…-task-stack-frame-budget # Conflicts: # ddtrace/internal/datadog/profiling/stack/echion/echion/config.h # ddtrace/internal/datadog/profiling/stack/echion/echion/echion_sampler.h # ddtrace/internal/datadog/profiling/stack/echion/echion/stacks.h # ddtrace/internal/datadog/profiling/stack/echion/echion/threads.h # ddtrace/internal/datadog/profiling/stack/fuzz/fuzz_echion_stacks.cpp # ddtrace/internal/datadog/profiling/stack/include/stack_renderer.hpp # ddtrace/internal/datadog/profiling/stack/src/echion/greenlets.cc # ddtrace/internal/datadog/profiling/stack/src/echion/stacks.cc # ddtrace/internal/datadog/profiling/stack/src/echion/threads.cc # ddtrace/internal/datadog/profiling/stack/src/sampler.cpp # ddtrace/internal/datadog/profiling/stack/src/stack.cpp # ddtrace/internal/datadog/profiling/stack/src/stack_renderer.cpp # ddtrace/internal/datadog/profiling/stack/test/test_sampling_cycle_state.cpp # releasenotes/notes/profiling-propagate-max-frames-f7a718abdfb99c4c.yaml # tests/profiling/collector/test_stack.py
taegyunkim
force-pushed
the
taegyunkim/prof-14213-task-stack-frame-budget
branch
from
September 14, 2026 20:44
7518df2 to
89c3bc8
Compare
This was referenced Sep 15, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Bounds asyncio stack collection and applies an explicit task-priority frame budget after logical stack stitching.
DD_PROFILING_MAX_FRAMESphysical thread framesDD_PROFILING_MAX_FRAMEScoroutine frames per task and stops assembling a logical task chain once its frame budget is full4 * max_frames, bounded between 256 and 1,024 entriesThe branch now includes
origin/mainateccb6f1d4a, including #19073. The merge preserves the upstreamUnwindResult, truncation-status, native-frame-limit, and post-fork restart behavior while applying the effective frame limit to asyncio collection. It also preserves the 599-frame configuration clamp from #19076, which reserves one of the backend's 600 locations for the omitted-frame indicator.JIRA: PROF-14213
Prerequisites
These independent changes should merge into
mainbefore this PR:DD_PROFILING_MAX_FRAMES.Testing
profiling_nativeCI matrix jobs. The exact local Python 3.12 ThreadSanitizer CI build command also passed against the merged tree, including all 23 native stack tests.scripts/lint checks, Python and C/C++ formatting, andgit diff --check. Broad static analysis reported only existing repository warnings.scripts/run-benchmarks --listfound no benchmark suite matching the changed Echion files.Risks
Low
DD_PROFILING_MAX_FRAMESvalues now intentionally prefer logical task ancestry over physical synchronous frames. This changes which portion of a mixed stack is reported, but preserves a bounded and logically meaningful task trace instead of allowing a deep physical stack to hide task context.If the asyncio boundary is outside the bounded physical candidates, the sampler does not guess at overlap or concatenate potentially duplicated segments. It reports the bounded logical task stack alone.
The smaller frame cache can increase cache churn in workloads with many distinct code locations. It retains a 256-entry floor and scales with configured stack depth to balance reuse and memory.
Additional Notes
Task selection and render ordering are intentionally separate. Task frames receive budgeting priority, while retained segments are still rendered in logical leaf-to-root order.
The uppercase
MAX_STACK_DISCOVERY_DEPTHconstant remains a hard internal safety bound. The configured reporting and materialization budget is stored separately instack_max_frames.