Skip to content

Python reachables: remove quadratic hotspots and resolve plain imports in nested files - #189

Merged
prabhu merged 6 commits into
mainfrom
perf/cdxgen-4419-python-reachables
Oct 1, 2026
Merged

prabhu merged 6 commits into
mainfrom
perf/cdxgen-4419-python-reachables

Conversation

@prabhu

@prabhu prabhu commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Speeds up reachables on large Python projects without changing results, and fixes how plain imports resolve in nested files. Part of cdxgen/cdxgen#4419.

Performance (hotspots found with JFR on django-DefectDojo and on a 400-copy Python fixture):

  • reachableByFlows tested each result against a list of sources. This was 59% of CPU on DefectDojo; it is now a set.
  • The Python import resolver ran a regex over every type decl for every import (54% on the fixture). It now builds a module-decl index once per pass, memoised by namespace.
  • METHOD_REF lookups for parameter tasks now go through a per-graph id index.
  • CdxPass.tagGeneric now matches each node set once per component.

Import resolution (changes results):

  • In a nested file, import pickle resolved to <dir>.pickle.loads, so those sinks were never tagged. Plain imports now resolve to the top-level module.
  • Policy: a module (or package, preferred) ingested next to the importer wins, as for a script run directly. Under python -m Python would pick the installed module instead.

Testing

  • The perf commits give identical flow fingerprints on 12 Python, JS and Java targets, and on 12,800 flows of the scale fixture.
  • With the import fix, all 40 copies of the probes reproduce the single-directory flows (0/40 before). No base flow is lost by atom's own containment rule.
  • New DottedFullNamesTests cases; full sbt test passes.

Related: cdxgen/cdxgen#4433 (cdxgen side), AppThreat/atom#267, AppThreat/atom-parsetools#14.

prabhu added 6 commits October 1, 2026 10:38
…bleByFlows

reachableByInternal tested every result against the list of sources, and
reachableByFlows tested every path element against the list of starting
points. Both were linear scans, so the cost grew with sources times results.
On a large Python project (django-DefectDojo) that scan was 59% of the CPU
samples of a reachables run. Node equality is by id with a matching hash, so
the sets answer exactly what List.contains did.
membersMatchingImports ran a regex over every type decl of the graph for
every import. On a 400-package fixture that was 54% of the CPU samples of a
whole reachables run. Only module-scope decls can contribute members, so they
are collected once per pass and filtered with String.contains, which is what
the quoted .*path.* regex matched, memoised per namespace.
Every task that starts at a parameter looked up the method references of its
method by filtering all METHOD_REF nodes, as the old TODO there suggested
caching. The per-graph index stores node ids (so it never pins a graph in
memory) and is rebuilt when the METHOD_REF count changes.
The purl, the component type and every description tag each re-ran the same
regex over all calls, identifiers, parameters and methods. The matches are
now computed once per component and reused, with tags added in the same
order.
`import X` is absolute, but the resolver prefixed the importing file's
directory, so `import pickle` in introduction/views.py became calls to
introduction.pickle.loads and `import subprocess` in a package became
pkg.subprocess.getoutput. Sink rules match full names, so those calls were
never tagged, and every flow into them was cut short or lost.

This also changes which module a plain import resolves to, by policy: a
module X ingested in the importer's own directory is preferred over a
top-level X. That is Python's answer when the file runs as a script (its
directory is on sys.path) and not under `python -m`, where a same-named
installed package would win; for reachables, following the code that is in
the graph is the safer choice. Among siblings, a regular package X/ is
preferred over a module file X.py, as CPython's finder does. Anything else
resolves as the top-level X, as before for files at the input root.

On a fixture of 40 copies of the framework and flow probes, every copy now
reports exactly the flows of the single-directory run; before, none did.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant