Skip to content

Commit b4cb61c

Browse files
committed
Whole-workspace class pre-resolution now spreads across cores
1 parent 7ff492e commit b4cb61c

6 files changed

Lines changed: 189 additions & 95 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5454
- **Laravel string keys are collected once instead of once per CPU core.** Checking a `route()`, `config()`, `view()`, or `__()` key needs the project's full list of valid keys, and building each list walks the project directory. The diagnostic pass runs one worker per core, so all of them used to reach the empty list at the same moment and every one repeated the same walk, leaving most cores waiting on the disk rather than analysing. Each list is now built once and shared, which roughly doubles the number of cores the diagnostic pass keeps busy and cuts whole-project analysis time by around 15% on large Laravel projects.
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.
57+
- **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.
5758

5859
### Removed
5960

docs/todo.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ unlikely to move the needle for most users.
199199
| P46 | [`mago-phpdoc-syntax` cannot parse `@method static (…) name()`](todo/performance.md#p46-mago-phpdoc-syntax-cannot-parse-method-static--name) | Low | Low |
200200
| 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 |
201201
| 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 |
202+
| 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 |
202203
| P16 | [Pre-parsed stub format (eliminate raw PHP embedding)](todo/performance.md#p16-pre-parsed-stub-format-eliminate-raw-php-embedding) | High | Medium-High |
203204
| 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 |
204205
| P22 | [Signature change re-queues slow diagnostics for every open file](todo/performance.md#p22-signature-change-re-queues-slow-diagnostics-for-every-open-file) | Medium-High | Medium |

docs/todo/performance.md

Lines changed: 85 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -816,36 +816,91 @@ waiting on the triggers above.
816816

817817
---
818818

819-
## P34. Eager class population is single-threaded
820-
821-
**Impact: Medium · Effort: Medium**
822-
823-
`populate_from_sorted` (`src/virtual_members/resolve.rs`) resolves the
824-
toposorted class list in a serial loop on one dedicated thread, in both
825-
the `analyze` Phase 1.5 and the LSP server's post-index
826-
`eager_populate_resolved_classes`. On a large Laravel project this is
827-
~1 s of single-core work in a release build and scales linearly with
828-
class count; on very large workspaces it is a visible single-threaded
829-
stretch between the (parallel) index and the (parallel) diagnostics
830-
pass.
831-
832-
Resolution is already safe to run concurrently: the cycle-break
833-
in-flight set is keyed by `(thread, FQN)`, and two threads resolving
834-
the same class merely duplicate work, with one result winning the
835-
cache write. Two directions:
836-
837-
1. **Work-stealing over the sorted list.** N workers pull indices from
838-
an atomic counter over the existing toposorted vector. Most
839-
dependencies are already cached by the time a dependent is pulled;
840-
occasional duplicated resolution near list neighbours is bounded and
841-
correct. Smallest change.
842-
2. **Topological generations.** Have the toposort emit levels
843-
(classes whose dependencies are all in earlier levels) and fan each
844-
level out across a scoped thread pool with a barrier between levels.
845-
No duplicated work, slightly more coordination.
846-
847-
Both need the workers on `PARSE_WORKER_STACK_SIZE` stacks, matching the
848-
current single populate thread.
819+
## P47. The resolved-class cache lock caps concurrent class resolution
820+
821+
**Impact: Medium · Effort: Medium-High**
822+
823+
`ResolvedClassCache` is a single `RwLock<ResolvedCacheInner>`, and every
824+
resolution takes its write lock several times: twice per
825+
`resolve_class_fully_inner` call to mark and clear the cycle-break
826+
in-flight marker, once to insert the finished class, and once per
827+
transformed member that `intern_transformed_method` /
828+
`intern_transformed_property` has to build (the read side of interning
829+
takes the read lock even on a hit). Member interning dominates the
830+
volume by far, since a merge produces one lookup per inherited or
831+
synthesized member.
832+
833+
Measured with the eager-population worker pool
834+
(`populate_from_sorted`) swept from 1 to 32 workers on a 16-core / 32-thread
835+
SMT machine (Ryzen 9 5950X), release build, large Laravel projects: wall
836+
time falls steeply to ~8 workers, flattens through 16, and then
837+
*regresses* past that. On one project 2.1k classes went 0.62 s
838+
(1 worker) → 0.16 s (8) → 0.27 s (32), i.e. 32 workers were worse than
839+
4. Duplicated resolution is not the cause: the count of full resolutions
840+
rises only 5.9 % from 1 to 32 workers.
841+
842+
Two confounds on this hardware make the raw sweep numbers hard to read
843+
at face value. The 16→32 leg is SMT: past 16 threads two workers share
844+
a physical core's execution resources rather than getting one each,
845+
which degrades throughput on its own and independently amplifies any
846+
lock contention (a spinning waiter now trashes the lock-holder's cache
847+
lines on the same core instead of a separate one). More importantly,
848+
the 5950X is dual-CCD — cores 0-7 and 8-15 (and their SMT siblings
849+
16-23/24-31) sit behind two separate L3 caches (`lscpu -e=CPU,CORE,CACHE`
850+
shows the split), and cache-line traffic for anything shared, including
851+
this lock, crosses the Infinity Fabric between them at much higher
852+
latency than a same-CCD hop. An unpinned process asking for ≤8 threads
853+
tends to get packed onto one CCD by the scheduler; past 8 it necessarily
854+
spills onto the second. Confirmed directly with `taskset`: 8 workers
855+
pinned to `0-7` (one CCD, no SMT) averaged 1.79 s wall for the same
856+
whole-project run that split 4+4 across both CCDs (`0,1,2,3,8,9,10,11`,
857+
still 8 distinct physical cores, still no SMT) averaged 2.02-2.17 s —
858+
15-20 % slower from CCD-crossing alone, with identical core count and
859+
no hyperthreading involved. So the knee at 8 in the original unpinned
860+
sweep is at least partly a topology artifact of this machine, not
861+
solely "how much lock contention exists at that many workers" — the
862+
same experiment on a single-CCD or single-die machine would likely
863+
plateau at a different worker count. `MAX_POPULATE_WORKERS` is pinned
864+
to 8 regardless: it is the largest value that stayed reliably within
865+
one CCD's worth of cores in testing, so it also happens to dodge the
866+
cross-CCD lock-traffic penalty as a side effect, not just diminishing
867+
per-lock-acquisition returns.
868+
869+
Implication for the fix: fixing the lock removes the *within-CCD*
870+
contention this section measured, and should let population usefully
871+
raise `MAX_POPULATE_WORKERS` above 8. But raising it past one CCD's
872+
core count re-introduces cross-CCD traffic for whatever of the lock
873+
(sharded or not) is still shared — a plain `Vec<Mutex<Shard>>` does not
874+
avoid that on its own. Re-run the `taskset` same-CCD-vs-split comparison
875+
above after any lock-splitting change before raising the cap past 8, to
876+
check how much of the remaining ceiling is the lock versus the CCD
877+
boundary.
878+
879+
The same lock is on the diagnostic pass's hot path (see P35), so
880+
splitting it should also help there. Directions, cheapest first:
881+
882+
1. **Take the in-flight set off the shared lock.** It is already keyed
883+
`(ThreadId, FQN)` purely to emulate a thread-local, so making it an
884+
actual thread-local `HashSet<Atom>` removes two write locks per
885+
resolution with no semantic change. Measured on its own this did
886+
*not* move the sweep, so it is a prerequisite rather than the fix,
887+
but it is nearly free.
888+
2. **Shard the interning tables.** `substituted_methods` /
889+
`substituted_properties` are keyed by origin pointer and a
890+
fingerprint hash, so they shard cleanly (e.g. by low bits of the
891+
key) into independent locks without changing what gets shared. This
892+
is where the volume is.
893+
3. **Separate the interning tables from the class map entirely.** The
894+
two have different access patterns (interning is
895+
write-heavy-then-read-heavy per merge; the class map is one insert
896+
per class) and only share a lock for convenience. Note the member
897+
sharing they provide is load-bearing for memory, so any change must
898+
keep cross-class sharing intact rather than falling back to
899+
per-thread tables.
900+
901+
Before implementing, confirm the attribution by counting lock
902+
acquisitions per site during a population run: the sweep above proves
903+
*a* lock is the ceiling but not which acquirer dominates.
849904

850905
---
851906

src/analyse/run.rs

Lines changed: 8 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -202,27 +202,14 @@ pub async fn run(options: AnalyseOptions) -> i32 {
202202
let uri_classes_index = backend.symbols.uri_classes_index.read();
203203
crate::toposort::toposort_from_uri_classes_index(&uri_classes_index)
204204
};
205-
// Run on a dedicated large-stack thread: `resolve_class_fully_inner`
206-
// can nest deeply when the toposort misses dependencies (stubs,
207-
// dynamically loaded classes), and this runs on a Tokio worker whose
208-
// stack is the 2 MB default rather than the main thread's 8 MB.
209-
std::thread::scope(|s| {
210-
let backend = &backend;
211-
let sorted_fqns = &sorted_fqns;
212-
std::thread::Builder::new()
213-
.name("eager-populate".into())
214-
.stack_size(crate::PARSE_WORKER_STACK_SIZE)
215-
.spawn_scoped(s, move || {
216-
let class_loader =
217-
|name: &str| -> Option<Arc<ClassInfo>> { backend.find_or_load_class(name) };
218-
crate::virtual_members::populate_from_sorted(
219-
sorted_fqns,
220-
&backend.resolved_class_cache,
221-
&class_loader,
222-
);
223-
})
224-
.expect("failed to spawn eager-population thread");
225-
});
205+
// `populate_from_sorted` fans the list out over its own large-stack
206+
// workers, so this needs no wrapper thread of its own.
207+
let class_loader = |name: &str| -> Option<Arc<ClassInfo>> { backend.find_or_load_class(name) };
208+
crate::virtual_members::populate_from_sorted(
209+
&sorted_fqns,
210+
&backend.resolved_class_cache,
211+
&class_loader,
212+
);
226213
// ── Phase 2: Collect diagnostics (parallel) ─────────────────────
227214
// Call individual collectors directly (instead of the grouped
228215
// collect_slow_diagnostics) so we can time each one independently.

src/backend/eager_population.rs

Lines changed: 9 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -43,25 +43,15 @@ impl Backend {
4343
let uri_classes_index = backend.symbols.uri_classes_index.read();
4444
crate::toposort::toposort_from_uri_classes_index(&uri_classes_index)
4545
};
46-
// Dedicated large-stack thread: class resolution walks
47-
// parsed ASTs and can nest when the toposort misses
48-
// dependencies (stubs, on-demand vendor loads).
49-
std::thread::scope(|s| {
50-
let backend = &backend;
51-
let sorted_fqns = &sorted_fqns;
52-
std::thread::Builder::new()
53-
.name("eager-populate".into())
54-
.stack_size(crate::PARSE_WORKER_STACK_SIZE)
55-
.spawn_scoped(s, move || {
56-
let class_loader = |name: &str| backend.find_or_load_class(name);
57-
crate::virtual_members::populate_from_sorted(
58-
sorted_fqns,
59-
&backend.resolved_class_cache,
60-
&class_loader,
61-
);
62-
})
63-
.expect("failed to spawn eager-populate thread");
64-
});
46+
// `populate_from_sorted` fans the list out over its own
47+
// large-stack workers, so this needs no wrapper thread of
48+
// its own.
49+
let class_loader = |name: &str| backend.find_or_load_class(name);
50+
crate::virtual_members::populate_from_sorted(
51+
&sorted_fqns,
52+
&backend.resolved_class_cache,
53+
&class_loader,
54+
);
6555
})
6656
.await;
6757
}

0 commit comments

Comments
 (0)