Skip to content

perf(profiling): bound task stack collection - #19085

Draft
taegyunkim wants to merge 29 commits into
mainfrom
taegyunkim/prof-14213-task-stack-frame-budget
Draft

taegyunkim wants to merge 29 commits into
mainfrom
taegyunkim/prof-14213-task-stack-frame-budget

Conversation

@taegyunkim

@taegyunkim taegyunkim commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Description

Bounds asyncio stack collection and applies an explicit task-priority frame budget after logical stack stitching.

  • materializes at most DD_PROFILING_MAX_FRAMES physical thread frames
  • materializes at most DD_PROFILING_MAX_FRAMES coroutine frames per task and stops assembling a logical task chain once its frame budget is full
  • gives logical task frames first claim on the final reporting budget
  • fills remaining capacity with synchronous leaf frames, followed by lower-priority event-loop roots
  • emits an inline synchronous-frame omission marker when physical frames are removed between retained logical segments
  • counts injected native and garbage-collection locations while selecting frames, preventing lower-priority physical context from displacing task context in the exporter
  • conservatively reports task context alone when an on-CPU asyncio boundary lies beyond the bounded physical stack
  • scales the Echion frame cache to 4 * max_frames, bounded between 256 and 1,024 entries

The branch now includes origin/main at eccb6f1d4a, including #19073. The merge preserves the upstream UnwindResult, 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 main before this PR:

Testing

  • Passed all 20 profiling_native CI matrix jobs. The exact local Python 3.12 ThreadSanitizer CI build command also passed against the merged tree, including all 23 native stack tests.
  • Passed the complete Python 3.13 profiling suite after rebuilding the native extensions: 410 passed, 35 skipped, and 2 expected failures.
  • Passed the targeted Python 3.13 gevent stack test for bounded greenlet discovery and the scaled frame-cache assertion.
  • The profiling runs covered task-priority frame budgeting, stack truncation, cache scaling, post-fork frame-limit updates, garbage-collection frames, native frames, and task reservoir sampling.
  • Passed scripts/lint checks, Python and C/C++ formatting, and git diff --check. Broad static analysis reported only existing repository warnings.
  • scripts/run-benchmarks --list found no benchmark suite matching the changed Echion files.

Risks

Low DD_PROFILING_MAX_FRAMES values 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_DEPTH constant remains a hard internal safety bound. The configured reporting and materialization budget is stored separately in stack_max_frames.

@cit-pr-commenter-54b7da

cit-pr-commenter-54b7da Bot commented Jul 15, 2026

Copy link
Copy Markdown

Codeowners resolved as

Resolved from the full PR diff against main using the target branch CODEOWNERS file.
CODEOWNERS team requests not listed below are not required by the current file set.

ddtrace/internal/datadog/profiling/stack/echion/echion/cache.h          @DataDog/profiling-python
ddtrace/internal/datadog/profiling/stack/echion/echion/config.h         @DataDog/profiling-python
ddtrace/internal/datadog/profiling/stack/echion/echion/echion_sampler.h  @DataDog/profiling-python
ddtrace/internal/datadog/profiling/stack/echion/echion/stacks.h         @DataDog/profiling-python
ddtrace/internal/datadog/profiling/stack/echion/echion/tasks.h          @DataDog/profiling-python
ddtrace/internal/datadog/profiling/stack/echion/echion/threads.h        @DataDog/profiling-python
ddtrace/internal/datadog/profiling/stack/fuzz/fuzz_echion_stacks.cpp    @DataDog/profiling-python
ddtrace/internal/datadog/profiling/stack/fuzz/fuzz_echion_task_unwind.cpp  @DataDog/profiling-python
ddtrace/internal/datadog/profiling/stack/include/constants.hpp          @DataDog/profiling-python
ddtrace/internal/datadog/profiling/stack/include/stack_renderer.hpp     @DataDog/profiling-python
ddtrace/internal/datadog/profiling/stack/src/echion/greenlets.cc        @DataDog/profiling-python
ddtrace/internal/datadog/profiling/stack/src/echion/stacks.cc           @DataDog/profiling-python
ddtrace/internal/datadog/profiling/stack/src/echion/tasks.cc            @DataDog/profiling-python
ddtrace/internal/datadog/profiling/stack/src/echion/threads.cc          @DataDog/profiling-python
ddtrace/internal/datadog/profiling/stack/src/sampler.cpp                @DataDog/profiling-python
ddtrace/internal/datadog/profiling/stack/src/stack_renderer.cpp         @DataDog/profiling-python
ddtrace/internal/datadog/profiling/stack/test/test_sampling_cycle_state.cpp  @DataDog/profiling-python
releasenotes/notes/profiling-bound-task-stack-frames-5c8460b0d7e2118f.yaml  @DataDog/apm-python
tests/profiling/collector/test_asyncio_task_frame_budget.py             @DataDog/profiling-python
tests/profiling/collector/test_stack.py                                 @DataDog/profiling-python

@cit-pr-commenter-54b7da

cit-pr-commenter-54b7da Bot commented Jul 15, 2026

Copy link
Copy Markdown

Circular import analysis

⚠️ Existing circular imports

There are 1 circular imports that already exist on the base branch and have not been changed by this PR.

ddtrace.errortracking._handled_exceptions.bytecode_injector -> ddtrace.errortracking._handled_exceptions.callbacks -> ddtrace.errortracking._handled_exceptions.collector -> ddtrace.errortracking._handled_exceptions.bytecode_reporting -> ddtrace.errortracking._handled_exceptions.bytecode_injector

@datadog-datadog-us1-prod

datadog-datadog-us1-prod Bot commented Jul 15, 2026

Copy link
Copy Markdown

Tests

All CI checks and tests passed.

🎉 All green!

🧪 All tests passed
❄️ No new flaky tests detected

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 7f050d0 | Docs | View more details | Give us feedback!

@taegyunkim
taegyunkim force-pushed the taegyunkim/prof-14213-propagate-max-frames branch from 32b532a to ecdbdab Compare July 15, 2026 20:02
@taegyunkim
taegyunkim force-pushed the taegyunkim/prof-14213-task-stack-frame-budget branch 2 times, most recently from 3b50116 to e3d2e8a Compare July 16, 2026 01:00
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
taegyunkim force-pushed the taegyunkim/prof-14213-propagate-max-frames branch from 67ae55e to c0a7c8f Compare July 16, 2026 21:17
@taegyunkim
taegyunkim force-pushed the taegyunkim/prof-14213-task-stack-frame-budget branch from b2e94b5 to 29fa643 Compare July 16, 2026 21:17
@taegyunkim
taegyunkim force-pushed the taegyunkim/prof-14213-propagate-max-frames branch from c0a7c8f to dd6bece Compare July 16, 2026 21:45
@taegyunkim
taegyunkim force-pushed the taegyunkim/prof-14213-task-stack-frame-budget branch from 29fa643 to 679beb6 Compare July 16, 2026 21:45
…-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
taegyunkim force-pushed the taegyunkim/prof-14213-task-stack-frame-budget branch from 9aa8d6c to 811e3b9 Compare August 19, 2026 19:08
@cit-pr-commenter-54b7da

cit-pr-commenter-54b7da Bot commented Aug 19, 2026

Copy link
Copy Markdown

Dependency direction analysis

⚠️ Existing dependency direction violations

There are 218 dependency direction violations that already exist on the base branch and have not been changed by this PR.

Show existing violations (showing 5 of 218 highest severity)
ddtrace.internal.tracemethods -×-> ddtrace.trace  (internal-core -> product:tracing, score=133)
ddtrace.llmobs._evaluators.runner -×-> ddtrace.trace  (product:llmobs -> product:tracing, score=131)
ddtrace.internal.test_visibility.api -×-> ddtrace.trace  (product:ci_visibility -> product:tracing, score=131)
ddtrace.debugging._exception.replay -×-> ddtrace.trace  (product:debugging -> product:tracing, score=131)
ddtrace.appsec._listeners -×-> ddtrace.trace  (product:appsec -> product:tracing, score=131)

To see all violations, download the layers-base.json and layers-pr.json artifacts from this CI job and run:

uv run --script scripts/import-analysis/layers.py compare layers-base.json layers-pr.json

…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
taegyunkim force-pushed the taegyunkim/prof-14213-task-stack-frame-budget branch from 7518df2 to 89c3bc8 Compare September 14, 2026 20:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant