Skip to content

perf(packages): resolve file paths without pathlib containment - #19857

Draft
P403n1x87 wants to merge 1 commit into
mainfrom
prf/path-matching
Draft

perf(packages): resolve file paths without pathlib containment#19857
P403n1x87 wants to merge 1 commit into
mainfrom
prf/path-matching

Conversation

@P403n1x87

Copy link
Copy Markdown
Collaborator

Description

filename_to_package and the is_user_code / is_third_party / is_stdlib predicates built on it are called per stack frame by the profiler and Dynamic Instrumentation. A production alloc-size profile attributed a large share of allocations to them, with PurePath._str_normcase and Path.__new__ at the top of the flame graph.

The cause is PurePath.relative_to / is_relative_to. On Python 3.12 a single call allocates path objects and performs case-folded comparisons proportional to the depth of both operands, whether it succeeds or fails. packages.py probed one filename against every sys.path entry across three separate fallback helpers, so almost all of that work was thrown away on the miss path.

Add ddtrace/internal/utils/paths.py, which answers the same questions by comparing the PurePath.parts pathlib has already parsed: containment becomes a prefix check over a tuple of strings, with no intermediate objects and no exceptions when the answer is no. relative_parts, is_contained, relative_path and deepest_containing_root replace the ad-hoc probing here and at eleven call sites in coverage/, ci_visibility/, testing/ and debugging/ that were open-coding the same patterns -- several of them paying for containment twice by calling is_relative_to and then relative_to.

Comparing parsed components rather than strings is also what makes the anchor handling correct: / and POSIX's separate //, or C: and C:\, no longer match each other, and normalization is guaranteed by PurePath construction rather than assumed.

Also cut redundant filesystem work the profile exposed:

  • _relative_to_known_root and _root_module each called path.resolve() on the same path. It is now resolved once in filename_to_package and passed alongside the original, which the sys.path probes still need unresolved or symlinked entries stop matching.
  • _is_install_root and _root_ships_distribution walked the same directory independently, the latter uncached on every probe. One cached _shipped_distributions listing now serves both.
  • The __init__.py probe behind _effective_root is cached on the package name instead of being repeated per source file.

Measured on 3.12. filename_to_package on a cache miss, against the real distribution mapping:

before   1198 us / 6634 B
after     284 us / 3138 B      4.2x faster, 53% less allocated

An Austin profile of the miss path (262k misses, 15.7 kHz, wall) confirms where the remaining cost sits and that containment is no longer part of it:

Path.resolve()                 89.3% of the miss path
parents-walk fallback           3.8%
containment helpers             1.3%

Removing the duplicate resolve took the miss from 57.1 us to 49.5 us, 13% end to end, and the follow-up profile shows resolve() at a single call site with the two helpers down from 23.4%/9.5% to 2.1%/1.6%.

The helpers are verified against pathlib itself rather than by construction: an exhaustive differential test over every path/root pair asserts they agree with relative_to, on 3.9 through 3.14. Because CI here is POSIX-only, the Windows rules are forced on and differentially tested too -- that is what caught a case folding bug, since the lowercase of U+0130 is two characters and an earlier offset-based implementation silently truncated components under a Turkish directory name.

No behaviour change is intended. Two latent bugs are fixed incidentally: an IndexError escaping filename_to_package when a path was itself a sys.path entry, and the PurePath.parents walk in _resolve_source_file no longer being quadratic in path depth.

`filename_to_package` and the `is_user_code` / `is_third_party` / `is_stdlib`
predicates built on it are called per stack frame by the profiler and Dynamic
Instrumentation. A production alloc-size profile attributed a large share of
allocations to them, with `PurePath._str_normcase` and `Path.__new__` at the top
of the flame graph.

The cause is `PurePath.relative_to` / `is_relative_to`. On Python 3.12 a single
call allocates path objects and performs case-folded comparisons proportional to
the depth of *both* operands, whether it succeeds or fails. `packages.py` probed
one filename against every `sys.path` entry across three separate fallback
helpers, so almost all of that work was thrown away on the miss path.

Add `ddtrace/internal/utils/paths.py`, which answers the same questions by
comparing the `PurePath.parts` pathlib has already parsed: containment becomes a
prefix check over a tuple of strings, with no intermediate objects and no
exceptions when the answer is no. `relative_parts`, `is_contained`,
`relative_path` and `deepest_containing_root` replace the ad-hoc probing here and
at eleven call sites in `coverage/`, `ci_visibility/`, `testing/` and
`debugging/` that were open-coding the same patterns -- several of them paying
for containment twice by calling `is_relative_to` and then `relative_to`.

Comparing parsed components rather than strings is also what makes the anchor
handling correct: `/` and POSIX's separate `//`, or `C:` and `C:\`, no longer
match each other, and normalization is guaranteed by `PurePath` construction
rather than assumed.

Also cut redundant filesystem work the profile exposed:

* `_relative_to_known_root` and `_root_module` each called `path.resolve()` on
  the same path. It is now resolved once in `filename_to_package` and passed
  alongside the original, which the `sys.path` probes still need unresolved or
  symlinked entries stop matching.
* `_is_install_root` and `_root_ships_distribution` walked the same directory
  independently, the latter uncached on every probe. One cached
  `_shipped_distributions` listing now serves both.
* The `__init__.py` probe behind `_effective_root` is cached on the package name
  instead of being repeated per source file.

Measured on 3.12. `filename_to_package` on a cache miss, against the real
distribution mapping:

    before   1198 us / 6634 B
    after     284 us / 3138 B      4.2x faster, 53% less allocated

An Austin profile of the miss path (262k misses, 15.7 kHz, wall) confirms where
the remaining cost sits and that containment is no longer part of it:

    Path.resolve()                 89.3% of the miss path
    parents-walk fallback           3.8%
    containment helpers             1.3%

Removing the duplicate resolve took the miss from 57.1 us to 49.5 us, 13% end to
end, and the follow-up profile shows `resolve()` at a single call site with the
two helpers down from 23.4%/9.5% to 2.1%/1.6%.

The helpers are verified against pathlib itself rather than by construction: an
exhaustive differential test over every path/root pair asserts they agree with
`relative_to`, on 3.9 through 3.14. Because CI here is POSIX-only, the Windows
rules are forced on and differentially tested too -- that is what caught a case
folding bug, since the lowercase of U+0130 is two characters and an earlier
offset-based implementation silently truncated components under a Turkish
directory name.

No behaviour change is intended. Two latent bugs are fixed incidentally: an
`IndexError` escaping `filename_to_package` when a path was itself a `sys.path`
entry, and the `PurePath.parents` walk in `_resolve_source_file` no longer being
quadratic in path depth.
@P403n1x87 P403n1x87 added the changelog/no-changelog A changelog entry is not required for this PR. label Aug 25, 2026
@cit-pr-commenter-54b7da

Copy link
Copy Markdown

Circular import analysis

⚠️ Existing circular imports

There are 3 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
ddtrace.llmobs -> ddtrace.llmobs._evaluators -> ddtrace.llmobs._evaluators.format -> ddtrace.llmobs._experiment -> ddtrace.llmobs
ddtrace.appsec._asm_request_context -> ddtrace.appsec._iast._iast_request_context_base -> ddtrace.appsec._iast._iast_env -> ddtrace.appsec._iast.reporter -> ddtrace.appsec._exploit_prevention.stack_traces -> ddtrace.appsec._asm_request_context

@cit-pr-commenter-54b7da

Copy link
Copy Markdown

Dependency direction analysis

⚠️ Existing dependency direction violations

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

Show existing violations (showing 5 of 250 highest severity)
ddtrace.internal.tracemethods -×-> ddtrace.trace  (internal-core -> product:tracing, score=135)
ddtrace.internal.opentelemetry.span -×-> ddtrace.trace  (product:opentelemetry -> product:tracing, score=133)
ddtrace.internal.openfeature._span_enrichment -×-> ddtrace.trace  (product:openfeature -> product:tracing, score=133)
ddtrace.internal.ci_visibility.recorder -×-> ddtrace.trace  (product:ci_visibility -> product:tracing, score=133)
ddtrace.profiling.collector.stack -×-> ddtrace.trace  (product:profiling -> product:tracing, score=133)

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

@datadog-official

datadog-official Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Pipelines  Tests

Unblock PR with BitsAI

⚠️ Warnings

Your PR has failed checks. Please review the issues below and take necessary action before merging.

🚦 5 Pipeline jobs failed

DataDog/apm-reliability/dd-trace-py | build linux serverless: [amd64, cp315-cp315, v113741238-d2b8243-manylinux2014_x86_64, 1] — 🔧 Needs a code fix, caused by this PR

View more details · View in GitLab

DataDog/apm-reliability/dd-trace-py | build linux: [amd64, cp315-cp315, v113741238-d2b8243-manylinux2014_x86_64] — 🔧 Needs a code fix, caused by this PR

View more details · View in GitLab

DataDog/apm-reliability/dd-trace-py | check-slo-breaches — 🔧 Needs a code fix, caused by this PR

View more details · View in GitLab

View all 5 failed jobs.

ℹ️ Info

No other issues found (see more)

🧪 All tests passed
❄️ No new flaky tests detected

🔄 Datadog auto-retried 6 jobs - 6 passed on retry View in Datadog

Useful? React with 👍 / 👎

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

@cit-pr-commenter-54b7da

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/debugging/_probe/model.py                                       @DataDog/debugger-python
ddtrace/internal/ci_visibility/api/_base.py                             @DataDog/ci-app-libraries
ddtrace/internal/ci_visibility/api/_coverage_data.py                    @DataDog/ci-app-libraries
ddtrace/internal/ci_visibility/api/_module.py                           @DataDog/ci-app-libraries
ddtrace/internal/coverage/code.py                                       @DataDog/ci-app-libraries
ddtrace/internal/coverage/report.py                                     @DataDog/ci-app-libraries
ddtrace/internal/packages.py                                            @DataDog/apm-core-python
ddtrace/internal/utils/paths.py                                         @DataDog/apm-core-python
ddtrace/testing/internal/pytest/_discovery.py                           @DataDog/ci-app-libraries
ddtrace/testing/internal/pytest/plugin.py                               @DataDog/ci-app-libraries
ddtrace/testing/internal/session_manager.py                             @DataDog/ci-app-libraries
ddtrace/testing/internal/tracer_api/coverage.py                         @DataDog/ci-app-libraries
tests/internal/test_packages_resilience.py                              @DataDog/apm-core-python
tests/internal/test_utils_paths.py                                      @DataDog/apm-core-python

@pr-commenter

pr-commenter Bot commented Aug 25, 2026

Copy link
Copy Markdown

Benchmarks

Benchmark execution time: 2026-08-25 15:19:09

Comparing candidate commit 59493e2 in PR branch prf/path-matching with baseline commit cdf0aa0 in branch main.

📊 Benchmarking dashboard

Found 0 performance improvements and 11 performance regressions! Performance is the same for 606 metrics, 10 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 ----------------------------------'

scenario:httppropagationextract-wsgi_valid_headers_all

  • 🟥 execution_time [+392.652ns; +456.859ns] or [+7.192%; +8.368%]

scenario:httppropagationinject-ids_only

  • 🟥 execution_time [+1.868µs; +2.020µs] or [+9.633%; +10.416%]

scenario:iastaspects-add_aspect

  • 🟥 execution_time [+18.591µs; +22.051µs] or [+18.770%; +22.263%]

scenario:iastaspects-join_aspect

  • 🟥 execution_time [+51.566µs; +56.661µs] or [+25.061%; +27.538%]

scenario:iastaspects-ljust_noaspect

  • 🟥 execution_time [+48.329µs; +55.421µs] or [+16.579%; +19.012%]

scenario:iastaspects-title_aspect

  • 🟥 execution_time [+59.998µs; +69.027µs] or [+22.090%; +25.414%]

scenario:iastaspectsospath-ospathbasename_aspect

  • 🟥 execution_time [+135.528µs; +142.255µs] or [+32.834%; +34.464%]

scenario:iastaspectssplit-rsplit_aspect

  • 🟥 execution_time [+22.448µs; +27.413µs] or [+16.153%; +19.726%]

scenario:span-start

  • 🟥 execution_time [+1.500ms; +1.662ms] or [+10.099%; +11.187%]

scenario:telemetryaddmetric-1-count-metric-1-times

  • 🟥 execution_time [+347.462ns; +388.715ns] or [+12.956%; +14.495%]

scenario:tracer-small

  • 🟥 execution_time [+24.546µs; +26.268µs] or [+7.347%; +7.862%]

Unstable benchmarks

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

scenario:coreapiscenario-context_with_data_listeners

  • unstable execution_time [-757.696ns; +719.365ns] or [-6.841%; +6.495%]

scenario:coreapiscenario-core_dispatch_1_listener

  • unstable execution_time [-34.936ns; +31.733ns] or [-5.710%; +5.187%]

scenario:coreapiscenario-core_dispatch_50_listeners

  • unstable execution_time [-1674.460ns; +1647.449ns] or [-9.810%; +9.651%]

scenario:coreapiscenario-core_dispatch_exception_listeners

  • unstable execution_time [-1174.140ns; +1273.815ns] or [-9.181%; +9.960%]

scenario:coreapiscenario-core_dispatch_listeners

  • unstable execution_time [-327.460ns; +325.550ns] or [-8.905%; +8.853%]

scenario:coreapiscenario-core_dispatch_no_args_listeners

  • unstable execution_time [-256.117ns; +253.944ns] or [-8.734%; +8.660%]

scenario:coreapiscenario-core_dispatch_with_results_1_listener

  • unstable execution_time [-61.102ns; +88.377ns] or [-5.305%; +7.673%]

scenario:coreapiscenario-core_dispatch_with_results_50_listeners

  • unstable execution_time [-3713.616ns; +4108.396ns] or [-9.277%; +10.263%]

scenario:coreapiscenario-core_dispatch_with_results_listeners

  • unstable execution_time [-740.718ns; +787.430ns] or [-9.203%; +9.784%]

scenario:packagesupdateimporteddependencies-import_many_stdlib_cached

  • unstable execution_time [-58.965µs; +62.523µs] or [-9.177%; +9.731%]

@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.

I don't see any obvious (or non obvious) issues

two characters, and an offset-based version silently truncated components.
"""
if _CASE_INSENSITIVE:
return all(a.lower() == b.lower() for a, b in zip(path_parts, root_parts))

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.

Is it guaranteed that normcase and lower are the same? I have virtually zero knowledge in file systems but it sounds like the kind of thing where file systems can be ✨ surprising ✨

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Once things are split into parts then it should be the same. This is only relevant for Win anyway 😅

root_parts = root.parts
n = len(root_parts)

if not n:

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
if not n:
if not root_parts:

Would that make sense in the interest of making things clearer?

Comment thread ddtrace/internal/utils/paths.py
Comment on lines 140 to +141
root = parent / base
return base if root.is_dir() and (root / "__init__.py").exists() else "/".join(rel_path.parts[:2])
return root.is_dir() and (root / "__init__.py").exists()

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 guess we're ok with using Pathlib here because we'd have to pay the price of transforming the path to a string anyway in order to do the same check without it?

+ I guess it shouldn't be called too often?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

these pathlib operations are fine as they don't allocate memory for new objects

"""Normalize a distribution name for comparison (PEP 503-ish).

``.dist-info`` / ``.egg-info`` directories escape the project name (dashes
become underscores), so fold ``-``, ``_`` and ``.`` to a single form and

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
become underscores), so fold ``-``, ``_`` and ``.`` to a single form and
become underscores), so fold -, _ and . to a single form and

To make the thing less... ugly? Not sure what to do with those cases

pass
relative = relative_parts(item_path, workspace_path)
if relative is not None:
# as_posix() of a relative path is its components joined by "/".

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
# as_posix() of a relative path is its components joined by "/".

I'd say remove this comment since we don't have as_posix anywhere anymore

Comment on lines +621 to +622
# Making a path relative only strips leading components, so the
# last one is unchanged.

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.

Same here, I think this comment only makes sense when you see the diff, otherwise it's puzzling more than it's helpful

try:
return f"/{Path(absolute_path).relative_to(relative_to)}"
except ValueError:
# Both sides go through Path, which is the normalization relative_path needs.

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
# Both sides go through Path, which is the normalization relative_path needs.

Comment on lines +403 to +409
"""A path that *is* a sys.path entry must resolve to None, not raise.

Such a path is relative to the root by zero components, so the root-module
lookup has no first component to inspect. _effective_root used to index
parts[0] unguarded, raising IndexError -- which filename_to_package does not
catch (it only handles ValueError/OSError), so it escaped to the caller.
"""

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
"""A path that *is* a sys.path entry must resolve to None, not raise.
Such a path is relative to the root by zero components, so the root-module
lookup has no first component to inspect. _effective_root used to index
parts[0] unguarded, raising IndexError -- which filename_to_package does not
catch (it only handles ValueError/OSError), so it escaped to the caller.
"""
"""A path that *is* a sys.path entry must resolve to None, not raise."""

I'd suggest getting rid of all the details in the test docstrings since they typically don't bring much useful info

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.

Are these changes necessary as part of the PR or could we split them into a follow-up PR for the purely CI Visibility / coverage-related changes?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

👍 we could separate the changes

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.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants