Skip to content

[2.2.3 perf regression fix] PackageDependencyResolver: memoize resolvePackage per file#5961

Merged
ondrejmirtes merged 1 commit into
phpstan:2.2.xfrom
SanderMuller:perf-memoize-resolve-package
Jul 1, 2026
Merged

[2.2.3 perf regression fix] PackageDependencyResolver: memoize resolvePackage per file#5961
ondrejmirtes merged 1 commit into
phpstan:2.2.xfrom
SanderMuller:perf-memoize-resolve-package

Conversation

@SanderMuller

@SanderMuller SanderMuller commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #5933, which introduced PackageDependencyResolver.

Problem

resolvePackage() maps a file to its owning Composer package by scanning every install path with str_starts_with(). It is called from NodeDependencies::getPackageDependencies() once per dependency file of every analysed file, so the same vendor files are resolved over and over, and each call is linear in the number of installed packages. The install-path map (getInstallPathToPackage()) is already memoized, but the per-file lookup was not.

A cold profile of a project with a large dependency tree (~265 installed packages, ~1800 files) shows 236,905 resolvePackage() calls and 62.9M str_starts_with() comparisons, all new in 2.2.3.

Fix

Memoize the result per file. Most dependency files belong to no project package (they are PHPStan's own bundled dependencies), so the common null result is cached too, since it otherwise pays a full scan on every call. PackageDependencyResolver is a singleton service, so the cache persists across all files in a worker. Same approach as 200a203 (DependencyResolver: reduce duplicate work).

Output is unchanged: the cache is keyed by file, and a file's owning package cannot change during a run.

Result

Cold analysis of the same project, result cache cleared before each run, interleaved across builds and repeated to be robust to machine load:

build cold CPU (user+sys) cold wall
2.2.2 201 s 29 s
2.2.3 293 s 40 s
2.2.3 + this change 215 s 30 s

This recovers about 85% of the regression. A residual ~7% above 2.2.2 remains after this change and is not resolvePackage (memoizing it does not move it further); it is elsewhere in the 2.2.2..2.2.3 range and I am narrowing it down separately.

The full test suite, self-analysis and the coding standard pass. The result-cache e2e scenarios behave identically with and without the change, as expected for a pure memoization.

@SanderMuller SanderMuller changed the title PackageDependencyResolver: memoize resolvePackage per file [2.2.3 perf regression fix] PackageDependencyResolver: memoize resolvePackage per file Jul 1, 2026
@staabm

staabm commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

how to reproduce the problem?

resolvePackage() maps a file to its owning Composer package by scanning
every install path with str_starts_with(). It is called from
NodeDependencies::getPackageDependencies() once per dependency file of
every analysed file, so the same vendor files are resolved over and
over, and each call is linear in the number of installed packages.

The install-path map is already memoized, but the per-file lookup was
not, so on projects with a large dependency tree this scan dominates
cold analysis. Memoize the result per file, including the null result
for files that belong to no project package (PHPStan's own bundled
dependencies), which otherwise pay a full scan on every call.

The resolver is a singleton service, so the cache persists across all
files in a worker. Same approach as 200a203 (DependencyResolver:
reduce duplicate work). The result is unchanged: the cache is keyed by
file, and a file's owning package cannot change during a run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@SanderMuller
SanderMuller force-pushed the perf-memoize-resolve-package branch from da143ca to b8713b5 Compare July 1, 2026 11:01
@SanderMuller

Copy link
Copy Markdown
Contributor Author

how to reproduce the problem?

Cold analysis of a project with a large dependency tree, comparing 2.2.2 to 2.2.3. On a project here (~265 installed packages, ~1800 files, level 7), interleaved cold runs (result cache cleared before each, repeated to be robust to machine load):

build cold CPU (user+sys) cold wall
2.2.2 201 s 29 s
2.2.3 293 s 40 s
2.2.3 + this PR 215 s 30 s

The same holds single-process (--debug): 141 s / 219 s / 155 s. A cold profile of 2.2.3 shows 236,905 resolvePackage() calls and 62.9M str_starts_with() comparisons, all new in 2.2.3, with the same vendor files resolved repeatedly. Memoizing per file recovers about 85%. I also measured a build with package-dependency tracking fully disabled and it was no faster than this PR, so the memo captures the recoverable cost.

On the memory report in #14892: this PR is a CPU fix. It does not reduce the stored packageDependencies map, so I would not expect it to move peak memory, and in my measurements peak memory was the same for 2.2.2, 2.2.3, this PR, and even with package tracking fully off. My project is smaller than the one in #14892, so I could not reproduce that memory delta here to validate a memory-side change. That looks like a separate fix targeting the per-file package-dependency storage.

There is also a residual ~7% above 2.2.2 that remains after this PR and is not resolvePackage (disabling it entirely does not move it further), so it is elsewhere in the 2.2.2..2.2.3 range; I am narrowing that down separately.

@ondrejmirtes
ondrejmirtes merged commit 1630169 into phpstan:2.2.x Jul 1, 2026
668 of 670 checks passed
@ondrejmirtes

Copy link
Copy Markdown
Member

Thank you!

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.

3 participants