Skip to content

Commit 558e611

Browse files
committed
Project startup is roughly three times faster
1 parent fbb2f7c commit 558e611

6 files changed

Lines changed: 399 additions & 189 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5555
- **Repeated class lookups are remembered instead of searched again.** Answering "which class does this name refer to?" is the single most frequent thing PHPantom does: analysing a large Laravel project asks it millions of times, over only a few thousand distinct classes. Every question used to hash the name and take a read lock on two shared indexes, so with one worker per CPU core the workers spent much of their time queueing behind each other instead of analysing. Each worker now remembers the answers it has already looked up and drops them the moment the class indexes change, so a repeated question costs a single pointer comparison. Whole-project analysis is 8-12% faster on large Laravel projects and uses up to a third less CPU, with identical results and no measurable change in memory use. Hover, completion, and go-to-definition resolve names through the same path and get the same saving.
5656
- **Vendor package scanning no longer reads every file twice.** Startup used to scan each vendor file once to find its classes, functions, and constants, then read and scan it again just to classify which package it came from for completion ranking. Each file's package is now known before it is scanned, so both are done in a single parallel pass, roughly halving the I/O and CPU cost of the vendor scan.
5757
- **Whole-workspace class pre-resolution now spreads across cores.** Resolving every known class in dependency order after indexing ran on a single core, leaving a single-threaded pause between the parallel index and the parallel diagnostic pass that grew with the number of classes in the project. That work is now shared across a pool of workers, which cuts the phase to roughly a quarter of its previous time on large Laravel projects and takes close to 20% off whole-project analysis on the largest of them. The editor's post-startup pre-resolution goes through the same path, so a large project becomes fully warm sooner. Results are unchanged.
58+
- **Project startup is roughly three times faster.** Building the class index at startup was the least parallel part of a run: it read every project and vendor file through a memory map, and mapping a file takes a lock that the whole process shares, so the more cores read files at once the longer each of them waited. Files are now read normally (only genuinely large ones are still mapped), the per-package directory walks run in parallel instead of one package after another, and both the file scan and the scan of a bundled `.phar` tool hand work out in small batches so no core sits idle behind a batch of large files. On large Laravel projects the startup phase drops from around 0.6s to 0.2s, taking 15-37% off whole-project analysis time across every project measured and up to 19% off its CPU use. The editor goes through the same pipeline, so it becomes usable sooner. Results are unchanged.
5859
- **The `analyze` and `fix` CLI subcommands no longer build the cross-file reference index.** That index only serves Find References, Rename, and reference-count inlay hints, none of which the CLI subcommands ever query, so every file they parsed was still paying to populate it. Skipping the build removes that wasted CPU and short-lived allocation from whole-project `analyze` and `fix` runs; the editor's LSP session is unaffected.
5960

6061
### Removed

docs/todo.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ unlikely to move the needle for most users.
197197
| | **[Performance](todo/performance.md)** | | |
198198
| P46 | [`mago-phpdoc-syntax` cannot parse `@method static (…) name()`](todo/performance.md#p46-mago-phpdoc-syntax-cannot-parse-method-static--name) | Low | Low |
199199
| P30 | [Evaluate migrating parse/resolve/docblock pipeline to `mago-hir`](todo/performance.md#p30-evaluate-migrating-parseresolvedocblock-pipeline-to-mago-hir) (parked — re-evaluated at mago 1.45.0, still no `mago-hir` consumers upstream) | Medium-High | High |
200-
| P43 | [`init_single_project` is the longest single-threaded stretch of a run](todo/performance.md#p43-init_single_project-is-the-longest-single-threaded-stretch-of-a-run) | Medium-High | Medium |
200+
| P48 | [Project init's remaining serial stretches](todo/performance.md#p48-project-inits-remaining-serial-stretches) | Low-Medium | Medium |
201201
| P47 | [The resolved-class cache lock caps concurrent class resolution](todo/performance.md#p47-the-resolved-class-cache-lock-caps-concurrent-class-resolution) | Medium | Medium-High |
202202
| P16 | [Pre-parsed stub format (eliminate raw PHP embedding)](todo/performance.md#p16-pre-parsed-stub-format-eliminate-raw-php-embedding) | High | Medium-High |
203203
| P25 | [`type_mismatch_argument` / `argument_count_mismatch` slow on large single files](todo/performance.md#p25-type_mismatch_argument-argument_count_mismatch-slow-on-large-single-files) | Medium | Medium |

docs/todo/performance.md

Lines changed: 47 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1049,34 +1049,60 @@ real ceiling than the hashing was.
10491049

10501050
---
10511051

1052-
## P43. `init_single_project` is the longest single-threaded stretch of a run
1052+
## P48. Project init's remaining serial stretches
10531053

1054-
**Impact: Medium-High · Effort: Medium**
1054+
**Impact: Low-Medium · Effort: Medium**
10551055

1056-
With the diagnostic pass now reaching ~22 of 32 cores (P35), project
1057-
init is the least parallel phase left and the largest remaining share
1058-
of wall clock. Per-phase timing of `analyze` on a large Laravel
1059-
project (32-core machine, release build, warm page cache):
1056+
`init_single_project` is down from 0.60 s at 3.2 of 32 cores to 0.21 s
1057+
at 6.2 (mmap contention, per-package collection, and the batch scanners'
1058+
static chunking are fixed), so the diagnostic pass is once again the
1059+
largest share of wall clock. What is left inside init, measured on a
1060+
large Laravel project (32-core machine, release build, warm page cache,
1061+
`utime + stime` from `/proc/self/stat` at each boundary):
10601062

1061-
| Phase | Wall | Avg cores |
1063+
| Step | Wall | Avg cores |
10621064
| --- | --- | --- |
1063-
| `init_single_project` | 1.09 s | 2.3 |
1064-
| `discover_user_files` | 0.03 s | 0.8 |
1065-
| Phase 1 index | 0.10 s | 7.1 |
1066-
| Phase 1.5 eager populate | 0.22 s | 1.0 |
1067-
| Phase 2 diagnostics | 1.31 s | 22.6 |
1068-
1069-
Init is ~40% of the run at barely two cores. It covers composer
1070-
reading, autoload/classmap scanning, stub setup, and the vendor
1071-
package scan, and it gates everything after it, so the same stretch is
1072-
in front of the LSP's time-to-usable as well as the CLI's. Worth a
1073-
per-phase breakdown inside init before choosing a fix — P34 (eager
1074-
population is single-threaded, the 1.0-core row above) is an
1075-
already-filed piece of the same window.
1065+
| vendor package collection (`collect_package_files`) | 0.060 s | 6.8 |
1066+
| vendor file scan (`scan_files_parallel_full`) | 0.033 s | 20.5 |
1067+
| `scan_autoload_files` | 0.038 s | 3.9 |
1068+
| `classify_class_origin` + `fqn_uri_index` fill | 0.019 s | 1.6 |
1069+
| workspace walk (`ignore` crate) | 0.018 s | 1.1 |
1070+
| `vendor_package_roots` | 0.006 s | 1.0 |
1071+
1072+
Each is small on its own; together they are most of what is left:
1073+
1074+
- **Vendor collection is per-package parallel, but one package is the
1075+
straggler.** `laravel/framework`'s PSR-4 tree is a single work item, so
1076+
the tail of the walk runs on one core. Splitting a package's PSR-4
1077+
roots (or its subdirectories) into separate work items would even it
1078+
out, but the concatenation has to stay in `installed.json` order for
1079+
duplicate FQNs to resolve to the same file.
1080+
- **Class origins are derived twice.** The vendor scan knows which
1081+
package each file came from, and records it for functions and constants
1082+
(`WorkspaceScanResult::function_origins` / `constant_origins`), but not
1083+
for classes. Init then re-derives every vendor class's origin by
1084+
prefix-matching its path against ~400 package roots, single-threaded.
1085+
Carrying class origins out of the scan the way the other two already
1086+
are would delete the step. The Composer-classmap path (`strategy =
1087+
"composer"`) has no origin for its entries, so the prefix match has to
1088+
stay as the fallback there.
1089+
- **`vendor_package_roots` re-reads and re-parses `installed.json`**
1090+
moments after `scan_vendor_packages_with_skip` parsed the same file,
1091+
and canonicalizes every package path a second time.
1092+
- **The workspace walk is single-threaded.** `ignore::WalkBuilder` has
1093+
`build_parallel`, but the walk order feeds the duplicate-FQN
1094+
tie-break, so a parallel walk needs its results sorted to stay
1095+
deterministic.
1096+
- **Reading and manifest-parsing a bundled `.phar` is serial.** With
1097+
phpstan installed this is 17 ms of the 24 ms `require_once` sweep; the
1098+
class scan inside the archive is already parallel.
10761099

10771100
Reproduce with the CPU-sampling loop in the Appendix, or by reading
10781101
`utime + stime` from `/proc/self/stat` at each phase boundary and
1079-
dividing by the phase's wall time.
1102+
dividing by the phase's wall time. Per-phase numbers for the run as a
1103+
whole (same project): init 0.21 s at 6.2 cores, `discover_user_files`
1104+
0.02 s at 1.1, Phase 1 index 0.02 s at 21.5, Phase 1.5 eager populate
1105+
0.05 s at 4.4, Phase 2 diagnostics 0.47 s at 20.9.
10801106

10811107
---
10821108

0 commit comments

Comments
 (0)