PHPantom — Performance¶
Internal performance improvements that reduce latency, memory usage, and lock contention on the hot paths. These items are sequenced so that structural fixes land before features that would amplify the underlying costs (parallel file processing, full background indexing).
Items are ordered by impact (descending), then complexity (ascending) within the same impact tier.
| Label | Scale |
|---|---|
| Impact | Critical, High, Medium-High, Medium, Low-Medium, Low |
| Complexity | Low (mechanical/boilerplate, no design decisions), Medium (self-contained, follows an existing pattern), Medium-High (spans modules, some new design), High (shared/core subsystem, correctness or performance tradeoffs), Very High (cross-cutting architecture, wide blast radius) |
P3. Parallel pre-filter in find_implementors¶
Impact: Medium · Complexity: Medium-High
find_implementors Phase 3 reads every unloaded classmap file
sequentially: fs::read_to_string, string pre-filter for the target
name, then parse_and_cache_file. On a project with thousands of
vendor classes, this loop is dominated by I/O latency. The string
pre-filter rejects most files (the target name appears in very few),
so the vast majority of reads are wasted.
Fix¶
Split Phase 3 into two sub-phases:
-
Parallel pre-filter. Collect the candidate paths into a
Vec<PathBuf>, then usestd::thread::scopeto read files and run theraw.contains(target_short)check in parallel. Return only the paths that pass the filter along with their content. -
Sequential parse. For the (few) files that pass, call
parse_and_cache_filesequentially. This step mutatesuri_classes_indexand callsclass_loader, which may re-lock shared state.
The same pattern applies to Phase 5 (PSR-4 directory walk for files not in the classmap). The pre-filter I/O is the bottleneck; the parse step processes very few files and is fast.
Note that once the full workspace index is ready,
find_implementors answers from Phase 1 alone — Phases 3 and 5
only run during the startup window before indexing completes, which
narrows how often this cost is paid.
Trade-off¶
Thread spawning overhead is only worthwhile when the candidate set is large. Skip parallelism when the candidate count is below a threshold (e.g. 8 files).
P15. Two-phase stub index construction (eliminate RwLock on stub maps)¶
Impact: Low · Complexity: Medium-High
The three stub indexes (stub_index, stub_function_index,
stub_constant_index) are write-once-read-many maps. They are
populated at construction time from the compiled-in phpstorm-stubs
arrays, then filtered once in set_php_version (called during
initialized) to evict entries with @removed X.Y tags. After
that single mutation they are never written again.
Because the PHP version is not known at construction time (it comes
from composer.json / .phpantom.toml, read during initialized),
the maps are currently wrapped in parking_lot::RwLock so that
set_php_version can call .write().retain(…). The maps are now
Arc<RwLock<…>>, shared across worker and request clones instead
of deep-copied per thread, so only the read-lock cost remains.
Every read — ~24 call sites across completion, resolution,
diagnostics, hover, and definition — acquires a shared read lock. On the
uncontended path this is a single atomic CAS (~1-5 ns), so the
cost is negligible in practice, but it is architecturally wasteful
for data that never changes after startup.
Ideal solution¶
Split Backend construction into two phases so that the stub maps
are plain HashMaps with zero synchronisation cost on reads:
-
Phase 1 — skeleton construction. Create the
Backendwith empty (or placeholder) stub maps. NoRwLockneeded because nothing reads them yet. -
Phase 2 — version-aware population. In
initialized, after detecting the PHP version, build the filtered maps (applyingis_stub_function_removed/is_stub_class_removedduring construction rather than viaretain) and store them on the backend through a one-shot setter that consumes the maps by value.
The setter could use std::sync::OnceLock<HashMap<…>> (or simply
an UnsafeCell behind a "set-exactly-once" assertion) to make the
write safe without ongoing read-side cost. Alternatively, the
fields can stay as plain HashMap if the Backend struct is built
in initialized rather than initialize — moving construction
after the version is known.
Prerequisites¶
This interacts with the test helpers (new_test,
new_test_with_stubs, etc.) which currently call
set_php_version in the constructor. They would need to accept
a PhpVersion parameter or build the filtered maps inline.
When to implement¶
Low priority. The current RwLock overhead is unmeasurable in
practice (~10-20 ns per completion request). Worth revisiting if
the stub indexes grow significantly or if Backend construction
is restructured for other reasons.
P16. Pre-parsed stub format (eliminate raw PHP embedding)¶
Impact: High · Complexity: Very High
The ~530 phpstorm-stubs PHP files are embedded as raw source via
include_str! (~9.8 MB in .rodata). This has three costs:
-
Permanent RSS. The 9.8 MB is memory-mapped into every process regardless of how many stubs are actually accessed. That is ~17% of the current 59 MB baseline and will become a larger relative share as vendor indexing grows the working set.
-
Parse cost on first access. Each stub is parsed with the full mago parser on first use (
parse_and_cache_content_versioned). Large files likeintl.php(296 KB) take several milliseconds. A Symfony project can trigger hundreds of stub parses as vendor classes extend built-in types. -
Duplicate data. After parsing, the
Arc<ClassInfo>lives inuri_classes_indexandfqn_index, but the raw PHP source stays resident in.rodataforever. Both copies exist simultaneously.
Indexing order: stubs → vendor → user¶
Background indexing will load data in dependency order:
- Stubs (built-in PHP classes, functions, constants)
- Vendor (Composer dependencies)
- User (project source)
This ordering means every layer's parent types are already
resolved before it starts. Vendor classes that extend ArrayAccess,
Iterator, JsonSerializable, etc. find pre-populated
fqn_index entries instead of triggering on-demand stub parses.
User classes that extend vendor classes find those already indexed
too.
With the current raw-PHP stubs, the stubs phase itself involves parsing ~530 PHP files through the full mago pipeline. In a pre-parsed format, this phase becomes a single deserialization step (~5-10 ms), making the stubs layer essentially free and letting vendor indexing start immediately.
Cascade cost during first-file-open¶
When the user opens a file before background indexing completes, the completion/hover path walks type chains synchronously. A typical Laravel file triggers a cascade like:
- Model →
find_or_load_class→ classmap → parse vendor PHP - Model implements
ArrayAccess,JsonSerializable,Countable, usesTraversable,Iterator,Stringable, etc. - Each of these hits Phase 3 (stub lookup) → full mago parse of the stub file containing it
- Stub files contain multiple classes, so parsing
SPL/SPL.phpforArrayAccessalso parsesIterator,Countable,SeekableIterator, etc.
A realistic first-open cascade triggers 20-40 stub file parses,
costing 40-200 ms of CPU time on the critical path. With
pre-parsed stubs, each stub lookup becomes a HashMap::get
returning an Arc<ClassInfo> in nanoseconds, eliminating this
cost entirely.
Solution¶
Parse all stubs at build time in build.rs (mago becomes a build
dependency) and serialize the extracted ClassInfo, FunctionInfo,
and constant data into a compact binary blob using postcard (or
bincode). Embed the blob via include_bytes!. At startup,
deserialize the blob and populate fqn_index directly.
Version filtering. Add since: Option<PhpVersion> and
until: Option<PhpVersion> fields to MethodInfo, ParameterInfo,
FunctionInfo, ClassInfo, and ConstantInfo. Embed one
"maximal" blob containing all version variants. After
deserialization, filter elements whose version range excludes the
target PHP version. This replaces both the current byte-level
@removed scanning at startup and the is_available_for_version
AST filtering at parse time.
Serde on the type hierarchy. Add #[derive(Serialize, Deserialize)]
to the core structs (ClassInfo, MethodInfo, PropertyInfo,
ConstantInfo, FunctionInfo, ParameterInfo, and their
supporting enums). SharedVec<T> needs a custom serde impl that
serializes as Vec<T> and deserializes into SharedVec::from(vec).
What gets removed:
- The
STUB_FILESarray (raw PHP source embedding) - The
phpantom-stub://URI scheme and associateduri_classes_indexentries - The
parse_and_cache_content_versionedpath for stubs - The
is_stub_function_removed/is_stub_class_removedbyte scanners (replaced by version fields on deserialized structs) - The
set_php_versionretain-based eviction (replaced by post-deserialize filtering)
Go-to-definition. Stubs are in-memory-only; the IDE cannot navigate to them anyway. No raw source needs to be preserved.
Hover. The extracted fields (class_docblock, deprecation_message,
links, see_refs, parameter type hints and names) are all
carried in the serialized structs. Hover quality is preserved.
Estimated impact¶
- Binary: −9.8 MB raw PHP, +2-3 MB serialized blob = net −7 MB
- RSS: 9.8 MB
.rodatano longer mapped; stubs loaded as heap-allocated structs filtered to the target PHP version - First-file-open: 40-200 ms of stub parse time on the critical path eliminated; stub lookups drop to nanoseconds
- Background indexing: stubs phase drops from seconds (parsing 530 PHP files) to <10 ms (deserializing one blob), letting vendor indexing start immediately
- Vendor indexing cascade: every vendor class that extends a
built-in type no longer triggers a stub parse; the parent
ClassInfois already infqn_index - Build time: clean builds gain 10-30 s for the mago parse
step; incremental builds unaffected (
write_if_changedcaching)
Prerequisites¶
serdederive on the core type hierarchy (already inCargo.toml)build.rsalready downloads stubs and generates code; extending it to parse PHP is incremental- Interacts with P15 (stub index
RwLockelimination): if stubs are deserialized eagerly, the two-phase construction in P15 becomes the natural approach
When to implement¶
High priority. This is a prerequisite for efficient stubs → vendor → user indexing. The 9.8 MB static cost is already meaningful and will become the dominant fixed overhead once vendor indexing is deferred. Implementing this before full vendor indexing lands avoids hitting the memory ceiling and ensures the stubs layer is essentially free for both eager and deferred indexing paths.
P17. mago-names resolution on the parse hot path¶
Impact: Medium · Complexity: High
The mago-names name resolver runs synchronously inside
update_ast_inner, adding a full AST walk plus an owned HashMap
copy on every didChange event. Measured regression from 6a0737a
("Migrate to use mago-names"):
| Benchmark | Before | After | Δ |
|---|---|---|---|
| with_narrowing | 12 ms | 15 ms | +25% |
| 5_methods_chain | 8 ms | 10 ms | +25% |
| carbon_class | 250 ms | 340 ms | +36% |
| large_file | 150 ms | 210 ms | +40% |
The resolved names are now consumed by many features through
resolve_name_at() (rename, references, semantic tokens,
go-to-definition, type hierarchy, highlight) and directly by
function resolution and deprecated diagnostics — but all of those
are on-demand request handlers. Nothing on the didChange or
completion hot path requires this data to be computed eagerly, so
lazy per-file-version resolution remains viable; it just has more
consumers to invalidate correctly than when this was filed.
Fix¶
Defer name resolution out of update_ast_inner. Options:
- Lazy resolution: compute
OwnedResolvedNameson first access per file version, invalidate on the nextupdate_ast. Moves the cost off the typing hot path entirely. - Diagnostic-worker resolution: run the resolver in the
diagnostic worker clone of
Backend, since diagnostics are the primary consumer.
When to implement¶
Low priority. The mago-names migration is complete, but the
use_map is still used by several consumers. Further refactoring
(migrating more consumers to byte-offset lookups, eventually
removing use_map) will change the access patterns. Optimizing
now would likely be reworked. Revisit once use_map usage is
significantly reduced.
P18. Subtype result caching¶
Impact: Medium · Complexity: High
PHPStan caches subtype check results (isSuperTypeOf()) in a static
HashMap keyed by type description strings. This avoids redundant
class hierarchy walks when the same type pair is checked multiple
times during a single request. PHPantom resolves class hierarchies
repeatedly during completion (checking if a method override is
covariant, checking if a class implements an interface, etc.). A
per-request HashMap<(String, String), bool> cache for subtype
results would reduce redundant hierarchy walks.
PHPStan also uses a hasTemplateOrLateResolvableType() fast-path
to skip expensive type traversal when a type has no template
parameters. PHPantom could add a similar flag to its type
representations to short-circuit template substitution on simple
types. Most types in a typical codebase are concrete (no generics),
so this fast-path would apply to the majority of checks.
Fix¶
-
Add a thread-local or per-request
HashMap<(Atom, Atom), bool>that caches the result of "is type A a subtype of type B?" lookups. Clear the map at the start of each completion/hover/diagnostic request. Class names are interned now (TypeKind::Namedcarries anAtom,ClassInfocaches its FQN as anAtom), so the keys areCopyand identity-hashed. Whole types are interned too, so a(PhpType, PhpType)key would work as well and hash just as cheaply. -
Add a
has_template_params: boolflag (or equivalent) toClassInfoor type representations. Set it during parsing when@templatetags or generic syntax are present. Before runningapply_substitution, check the flag and skip the substitution walk entirely when it isfalse. (Today the equivalent guarding is ad hoc emptiness checks ontemplate_paramsand on the substitution map.)
Appendix: Profiling¶
Commands¶
# Record (Ctrl-C after ~60s):
perf record -g --call-graph dwarf -- \
./target/release/phpantom_lsp analyze \
src/core/Purchase/Services/PurchaseFileService.php
# Text report (top functions):
perf report --stdio --no-children | head -80
# Flamegraph (requires the `flamegraph` crate or perf-tools):
perf script | flamegraph > /tmp/phpantom.svg
# Instantaneous CPU utilisation over a run (% of one core, sampled
# every 0.5 s), for machines where perf is unavailable
# (kernel.perf_event_paranoid > 2):
./target/release/phpantom_lsp analyze --project-root <dir> --no-colour \
>/dev/null 2>&1 & PID=$!; prev=0
while kill -0 $PID 2>/dev/null; do
cur=$(awk '{print $14+$15}' /proc/$PID/stat 2>/dev/null) || break
[ -n "$cur" ] && echo $(( (cur - prev) * 2 )); prev=$cur; sleep 0.5
done
Pathological test file¶
PurchaseFileService.php (~700-line Eloquent-heavy service with
~55 imports) is the most expensive single file encountered so far.
The per-collector timing is controlled by a >= 2s threshold in
src/analyse.rs Phase 2 (search for ⏱). It prints a breakdown
like:
⏱ 63.2s src/core/Purchase/Services/PurchaseFileService.php
[fast=1ms cls=40ms mem=23696ms fn=12ms unres=16781ms arg=22568ms impl=0ms depr=54ms]
P20. Content-hash gated resolution cache persistence¶
Impact: Medium · Complexity: Very High
The resolved-class cache (resolved_class_cache) is ephemeral — it
lives only for the duration of the process. On LSP restart or cold
start, all class resolution (inheritance merging, virtual members,
template substitution) is re-computed from scratch even when files
haven't changed.
Fix: Persist resolved ClassInfo entries to a project-local cache
directory, keyed by xxh128(file_contents). On startup, walk the
project, compare content hashes, and load cached entries for unchanged
files. Only re-resolve classes whose source files (or dependency files)
have changed.
Psalm implements exactly this pattern with three cache layers:
- Parser cache (serialized AST, keyed by file content hash)
- File storage cache (classes-in-file, functions, constants)
- ClassLike storage cache (methods, properties, template types,
parent chains — keyed by xxh128(file_contents))
Each layer checks the hash on load and discards stale entries. Schema
versioning (tracking filemtime of the storage struct source files)
auto-invalidates all caches when internal types change.
Design:
- Use
bincodeserialization (already evaluated in X6) forClassInfoentries. - Key:
(fqn, content_hash)→ serializedClassInfo. - On startup: load cache entries where content hash matches current file. Skip resolution for those classes entirely.
- On file change: evict entries for the changed file AND entries whose classes depend on changed members (using the existing dependency tracking from ER4).
- Schema version: embed a version constant derived from
ClassInfostruct layout. Invalidate entire cache on version mismatch.
Relationship to X6: X6 (disk cache) is the broader evaluation of whether disk caching is worthwhile. P20 is the specific application to resolved-class storage, which is the most expensive thing to recompute. P20 can ship independently as a targeted optimization even if the broader X6 evaluation concludes that full disk caching isn't needed.
References:
- Psalm: ClassLikeStorageCacheProvider in
Psalm\Internal\Provider\ClassLikeStorageCacheProvider
- Psalm: FileStorageCacheProvider for the content-hash invalidation
pattern
- A peer PHP LSP project persists its per-file index cache on disk
keyed by blake3(uri || content). It originally keyed on
mtime + size and shipped a cache-staleness bug (a size-preserving
edit within the same mtime second was missed) before switching to
content hashing. Confirms the content-hash-as-authority choice
above; never trust mtime for correctness, at most as a cheap
pre-filter to skip hashing unchanged files.
P21. Offset-shifting for cached diagnostics on partial edits¶
Impact: Medium · Complexity: Very High
When a user edits one method in a file, PHPantom currently re-runs diagnostics on the entire file. For large files (500+ lines), this is wasteful — diagnostics in unchanged regions are still valid, just at shifted byte offsets.
Fix: After a file edit, compute a line-level diff (Myers algorithm) to produce byte-offset shift deltas. Apply the deltas to cached diagnostics in unchanged regions. Only re-diagnose methods whose byte ranges overlap with the edited region.
Psalm implements this with:
1. FileDiffer — Myers line-level diff producing byte-offset ranges
2. FileStatementsDiffer — AST-level statement diff classifying
statements as keep/keep_signature/add_or_delete
3. shiftFileOffsets() — shifts surviving diagnostics/references by
the offset delta, removes those in deleted ranges
Design:
- On
didChange, compute a line diff between old and new content. - Produce a
diff_map: Vec<(old_start, old_end, offset_delta)>. - Walk cached diagnostics for this file:
- If diagnostic span falls in a deleted range → remove it.
- If diagnostic span is after the edit → shift by delta.
- If diagnostic span is before the edit → keep as-is.
- Re-run diagnostics only for methods/functions whose spans overlap with changed regions (use the member-level AST diff from ER4's incremental repopulation).
- Merge shifted cached diagnostics with freshly-computed ones.
Prerequisites: The incremental repopulation (ER4) already identifies which members changed. This task extends that to the diagnostic layer.
References:
- Psalm: FileDiffer and FileStatementsDiffer in
Psalm\Internal\Diff
- Psalm: Analyzer::shiftFileOffsets() for the offset-shifting logic
P30. Evaluate migrating parse/resolve/docblock pipeline to mago-hir¶
Impact: Medium-High · Complexity: Very High
mago-hir is an intermediate representation that lowers the CST plus
PHPDoc comments into a single flat,
fully-resolved tree in one pass: names are resolved
(Local/Qualified/FullyQualified + imported flag on every
identifier), docblock tags are parsed into structured annotations
(@template, @extends/@implements generics, @mixin,
@method/@property, @param/@return/@throws,
@assert/@assert-if-true/@assert-if-false, @param-out,
@self-out, type aliases), and types are parsed into a full
PHPStan/Psalm-grade type language (generics with resolved bounds,
conditional types, key-of/value-of, array/object shapes,
int-ranges, class-string variants, int-masks). mago-phpdoc-syntax
already supplies the docblock and type half of that; what mago-hir
adds on top is the resolved-name and single-tree lowering PHPantom
hand-rolls across parser/ and names.rs.
The IR threads three generic "hole" parameters
(IR<'arena, I, S, E>, defaulting to ()) through every node so a
later inference pass can fill in resolved type information at
item/statement/expression granularity without changing the tree
shape — this is the "groundwork for the upcoming rule-based checker"
azjezz described.
Confirmed by reading the source directly (docs.rs is only
~1% documented, so don't rely on it): mago-hir depends only on
crates we already use (mago-syntax, mago-syntax-core,
mago-phpdoc-syntax, mago-span, mago-database,
mago-allocator) plus the small mago-flags crate. It does not
pull in mago-codex, mago-analyzer, or mago-reflection, so
adopting it would not introduce a second type-resolution engine
alongside our own (see the "no parallel type resolution systems"
rule in CLAUDE.md) — it would replace the raw-parsing layer that
currently feeds ClassInfo construction, not ClassInfo itself or
resolve_rhs_expression/resolve_expression_type.
Potential payoff if it holds up:
- Deletes a large share of our hand-rolled docblock tag parsing, type-string parsing, and name resolution, replacing it with a single upstream-maintained pass.
- A natural path to Blade support: lowering Blade's compiled-PHP
approximation (see
examples/laravel/Blade handling) to the same IR would let more code actions and diagnostics work uniformly on Blade files instead of only a subset, as flagged in the Discord discussion with azjezz.
Do not start this now. The crate is ~19k LOC of essentially undocumented API with no consumers, and was described as under active redesign ("final touches... in the next branch" per azjezz).
Note for anyone re-reading the history here: mago-hir did not
arrive in 1.44.0. It first shipped in 1.40.0 (2026-06-24) and had
already reached its current size by 1.43.0. The "brand new crate"
framing in the original writeup was wrong, which matters because it
means the API-settling clock below started earlier than assumed.
Triggers to revisit — start only once at least two of these hold:
mago-hirhas shipped unchanged (no breaking API changes) across at least 2-3 minor mago releases, indicating the API has settled.- Upstream's own rule-based checker/analyzer ships on top of
mago-hirand is in real use, proving the IR's "holes" mechanism works end-to-end for type inference, not just as a parse target. - rustdoc coverage for
mago-hiris substantially more complete (the 1.44.0 release is ~1% documented), or azjezz confirms the shape is stable enough to build against.
Re-evaluated at mago 1.45.0 (2026-07-29): one of three triggers holds, so this stays parked and the prototype below was not run.
- API settled — technically yes, but for the wrong reason. The
mago-hirsources are byte-identical across 1.43.0, 1.44.0 and 1.45.0 (verified by unpacking the crates and diffing: zero changed lines). That is not an API that settled through use, it is one that has seen no development at all. - Upstream checker builds on it — no, and upstream picked something
else.
mago-analyzer,mago-linterandmago-codexat 1.45.0 all depend onmago-syntaxplusmago-phpdoc-syntaxdirectly, and none of them depends onmago-hir.mago-hirhas zero reverse dependencies on crates.io. Six releases and a month after it appeared, the rule-based checker it was billed as groundwork for is being built on a different foundation. This is the load-bearing trigger: adopting the IR now would make PHPantom its first and only user, betting the parse layer on an inference "holes" mechanism that nothing has exercised end to end. - rustdoc coverage — no. 84 doc-comment lines against 876
pubitems in 1.45.0, still about the ~1% the original writeup found.
The cheap way to re-check is the second trigger on its own: if
anything in mago's own workspace starts depending on mago-hir, or
its crates.io reverse-dependency count moves off zero, that flips the
one trigger that carries real evidence and this becomes worth another
look.
Re-checked at mago 1.46.0 (2026-08-13): unchanged. mago-hir still
has zero reverse dependencies on crates.io, and docs.rs coverage is
still around 1%. Still parked.
Before committing to a full migration, prototype first: feed
symbol_map extraction (or ClassInfo construction) for a single
file from IR behind a flag, on a branch, and compare output against
the current extraction plus wall-clock time. Only proceed to a
broader migration if the prototype reproduces current behavior and
shows a real win; otherwise record the findings here and keep
waiting on the triggers above.
P47. The resolved-class cache lock caps concurrent class resolution¶
Impact: Medium · Complexity: Very High
ResolvedClassCache is a single RwLock<ResolvedCacheInner>, and every
resolution takes its write lock several times: twice per
resolve_class_fully_inner call to mark and clear the cycle-break
in-flight marker, once to insert the finished class, and once per
transformed member that intern_transformed_method /
intern_transformed_property has to build (the read side of interning
takes the read lock even on a hit). Member interning dominates the
volume by far, since a merge produces one lookup per inherited or
synthesized member.
Measured with the eager-population worker pool
(populate_from_sorted) swept from 1 to 32 workers on a 16-core / 32-thread
SMT machine (Ryzen 9 5950X), release build, large Laravel projects: wall
time falls steeply to ~8 workers, flattens through 16, and then
regresses past that. On one project 2.1k classes went 0.62 s
(1 worker) → 0.16 s (8) → 0.27 s (32), i.e. 32 workers were worse than
4. Duplicated resolution is not the cause: the count of full resolutions
rises only 5.9 % from 1 to 32 workers.
Two confounds on this hardware make the raw sweep numbers hard to read
at face value. The 16→32 leg is SMT: past 16 threads two workers share
a physical core's execution resources rather than getting one each,
which degrades throughput on its own and independently amplifies any
lock contention (a spinning waiter now trashes the lock-holder's cache
lines on the same core instead of a separate one). More importantly,
the 5950X is dual-CCD — cores 0-7 and 8-15 (and their SMT siblings
16-23/24-31) sit behind two separate L3 caches (lscpu -e=CPU,CORE,CACHE
shows the split), and cache-line traffic for anything shared, including
this lock, crosses the Infinity Fabric between them at much higher
latency than a same-CCD hop. An unpinned process asking for ≤8 threads
tends to get packed onto one CCD by the scheduler; past 8 it necessarily
spills onto the second. Confirmed directly with taskset: 8 workers
pinned to 0-7 (one CCD, no SMT) averaged 1.79 s wall for the same
whole-project run that split 4+4 across both CCDs (0,1,2,3,8,9,10,11,
still 8 distinct physical cores, still no SMT) averaged 2.02-2.17 s —
15-20 % slower from CCD-crossing alone, with identical core count and
no hyperthreading involved. So the knee at 8 in the original unpinned
sweep is at least partly a topology artifact of this machine, not
solely "how much lock contention exists at that many workers" — the
same experiment on a single-CCD or single-die machine would likely
plateau at a different worker count. MAX_POPULATE_WORKERS is pinned
to 8 regardless: it is the largest value that stayed reliably within
one CCD's worth of cores in testing, so it also happens to dodge the
cross-CCD lock-traffic penalty as a side effect, not just diminishing
per-lock-acquisition returns.
Implication for the fix: fixing the lock removes the within-CCD
contention this section measured, and should let population usefully
raise MAX_POPULATE_WORKERS above 8. But raising it past one CCD's
core count re-introduces cross-CCD traffic for whatever of the lock
(sharded or not) is still shared — a plain Vec<Mutex<Shard>> does not
avoid that on its own. Re-run the taskset same-CCD-vs-split comparison
above after any lock-splitting change before raising the cap past 8, to
check how much of the remaining ceiling is the lock versus the CCD
boundary.
The same lock is on the diagnostic pass's hot path (see P35), so splitting it should also help there. Directions, cheapest first:
- Take the in-flight set off the shared lock. It is already keyed
(ThreadId, FQN)purely to emulate a thread-local, so making it an actual thread-localHashSet<Atom>removes two write locks per resolution with no semantic change. Measured on its own this did not move the sweep, so it is a prerequisite rather than the fix, but it is nearly free. - Shard the interning tables.
substituted_methods/substituted_propertiesare keyed by origin pointer and a fingerprint hash, so they shard cleanly (e.g. by low bits of the key) into independent locks without changing what gets shared. This is where the volume is. - Separate the interning tables from the class map entirely. The two have different access patterns (interning is write-heavy-then-read-heavy per merge; the class map is one insert per class) and only share a lock for convenience. Note the member sharing they provide is load-bearing for memory, so any change must keep cross-class sharing intact rather than falling back to per-thread tables.
Before implementing, confirm the attribution by counting lock acquisitions per site during a population run: the sweep above proves a lock is the ceiling but not which acquirer dominates.
P35. Diagnostic passes reach only a fraction of available cores¶
Impact: Medium-High · Complexity: Very High
Measured on a 32-core machine against large Laravel projects (release
build): the analyze Phase 2 diagnostic pass spawns one worker per
core with atomic work-stealing but does not keep them busy. Three
offenders are fixed. The original one was every worker deep-cloning
the embedded stub class/function/constant indexes twice per file via
clone_for_diagnostic_worker; the indexes are Arc-shared now. The
second was the Laravel string-key enumerations
(cached_route_names/cached_config_keys/cached_view_names/
cached_trans_keys/cached_config_trees): each walks the workspace
from disk, and the plain check-then-fill cache stampeded, so all 32
workers missed the same empty slot at once and each repeated the same
gitignore-aware walk. They are guarded by
LaravelStringKeyBuildLocks now, which took Phase 2 on a large
Laravel project from 11.6 to 22.6 of 32 cores (1.91 s → 1.31 s) and
whole-run wall clock down ~15%.
The third was the name → class loader itself. find_or_load_class_typed
was called 3.68 M times in a 1.2 s Phase 2 over a few thousand distinct
types, and every call hashed the name case-insensitively for a read
lock on class_not_found_cache and another on fqn_class_index. That
cluster (find_or_load_class_typed, find_class_in_uri_classes_index,
CiMap::get, sip::Hasher::write, RawRwLock::lock_shared_slow,
kernel osq_lock) was ~22% of Phase 2 samples, and both lock symbols
have since dropped out of the profile entirely. class_loader_memo
memoises the loader per worker on the interned PhpType handle, keyed
by SymbolIndex::id and stamped with class_lookup_generation so an
answer is never staler than the two caches it derives from. Phase 2 fell
~25-32% (1.23 s → 0.93 s and 0.72 s → 0.49 s on the two largest Laravel
projects benchmarked) at ~26.8 → ~28.6 of 32 cores, with whole-run wall
clock down 8-12% and user CPU down 17-34% across three large Laravel
projects. Every smaller project benchmarked improved as well, RSS did
not move, and diagnostic output was byte-identical on all ten.
Sampling /proc/<pid>/task/*/stat is the fastest way to see the
remaining ceiling: workers in S rather than R are blocked, not
computing. What is left, re-profiled after the memo:
- Type strings are still parsed during the diagnostic pass rather than
at index time:
TypeTokenStream::fill_buffer_slow,PhpType::parse,LocalArena::alloc_slice_copyandparse_primary_typetogether are ~6.5% of Phase 2 samples. Class-level@method/@propertytags are now parsed at extraction time; the remaining cost is method, parameter and return type strings. - malloc/free/memmove remains diffuse.
is_scalar_nameandis_keyword_type(~2.3% between them) allocate a lowercaseStringper call and are reached frombase_name, the subtype checks and the narrowing paths; the ~150 otherto_ascii_lowercase()calls inphp_type/do the same. A stack buffer plus a length pre-filter would make them allocation-free, but see the reverted attempt below before assuming that wins. - The
PhpTypeinterner (php_type::intern+intern::lookup) is ~4.6% of Phase 2 samples and its 64 shards are now the largest remaining lock, though the memo removed enough traffic thatlock_shared_slowno longer registers at all. An earlier count put the interner at ~13 M hits per pass with roughly a quarter falling through the shard read lock to the write path; that has not been re-counted since. A per-thread direct-mapped memo in front of the shard read (the same shape asclass_loader_memo) is the obvious next attempt. ensure_workspace_indexed_with_progressstill re-walks the whole workspace on every call, so the four surviving string-key enumerations do four full walks per diagnostic pass (down from ~44). The walk is deliberate — it is how PHP files created outside the editor get discovered — so removing it needs a change to that contract, not just another cache.class_loader_memo's own hit rate is bounded by how oftennote_class_lookup_changefires: 1,169 times in Phase 2, once per lazily parsed vendor file, each retiring every worker's table. A build with invalidation removed (unsound, for sizing only) reached 0.80 s against the 0.93 s shipped, so ~14% of the memo's prize is still on the table. Recovering it means either loading fewer vendor classes lazily (the reverted experiment below, whose calculus this changes) or splitting the generation so an additive insert only retires negative answers. The latter is not sound as stated: a positive answer reached through PSR-4 or a stub is matched by short name, so it is not always backed by anfqn_class_indexentry under the name that was looked up, and a later first-time insert of that exact name can change it.
The LSP workspace diagnostics pass uses the same collectors and has the
same ceiling. Re-measure with perf (frame-pointer build) or the
CPU-sampling loop in the Appendix after any change.
Tried and reverted (vendor classes in eager population): an
earlier revision of this item claimed that projects keeping
substantial code in vendor/ pay extra because eager population only
walks the indexed user files, leaving every vendor class to be parsed
and resolved lazily mid-diagnostic — serialising workers on the load
path, with cycle-break re-merges that dependency-first ordering would
have avoided. Seeding eager population with vendor classes was
implemented in two escalating variants and benchmarked (10-run,
order-swapped wall/user-CPU averages against the two largest Laravel
projects benchmarked): (1) expanding the toposort input with the
transitive inheritance closure of the user classes (parents, traits,
interfaces, mixins, generic arguments, loaded via
find_or_load_class), and (2) additionally seeding every use-import
target found in fqn_uri_index, with parallel frontier loading and
Kahn-levelled parallel resolution to keep Phase 1.5 off the critical
path. Neither moved wall clock on any project. Variant 1 cut lazy
Phase 2 resolutions only ~2% (950 → 931) because vendor ancestors
were already being resolved as nested resolutions during eager
population; variant 2 cut them to a third (931 → 276) but cost ~3%
more user CPU (duplicated nested provider resolutions across level
workers) and ~10 MB RSS for classes the pass never needed resolved.
The predicted cycle-break re-merges also failed to reproduce: Phase 2
hits 0 on one of them and 18 on the other, and all
18 are genuine dependency cycles (Schedule ↔
PendingEventAttributes, Spatie Role ↔ Permission) that
dependency-first ordering cannot avoid — the toposort has to break
them somewhere too. Conclusion: after the stampede fix, lazy vendor
class loading no longer serialises the diagnostic pass measurably;
the remaining ceiling is the clone/interner traffic above. Diagnostic
output was byte-identical in every configuration.
Tried and reverted: a single perf snapshot attributed ~4% of
Phase 2 time to core::hash::sip::Hasher::write, called from
CiMap/CiSet (fqn_class_index, class_not_found_cache) inside
find_or_load_class — the single hottest function in the profile.
Two independent fixes were tried: swapping CiMap/CiSet's HashMap
from std's default SipHash to a hand-rolled FxHash-style hasher, and
avoiding fold()'s per-lookup heap allocation with a stack buffer.
Both looked sound in isolation and passed all tests, but 10-run
wall-clock/user-CPU averages against a large Laravel project (order-swapped
to rule out warm-cache bias) showed a small, consistent regression
(~2% more user CPU) for the hasher swap alone, the allocation-avoidance
alone, and the two combined. Likely cause: mimalloc already makes
these transient allocations cheap, and the hand-rolled hasher's
sequential dependency chain (rotate_left → xor → wrapping_mul per
word) didn't beat std's SipHash13 for these short keys on this
hardware. Lesson for next attempt: a single perf --stdio percentage
is not sufficient evidence — confirm with repeated, order-controlled
wall-clock measurement before committing a "hot function" fix; sampling
noise and inlining attribution can point at the wrong function. The
re-measurement did surface a more promising lead: RawRwLock::lock_shared_slow
rose from 3.78% to 4.36% of samples once the hashing/allocation cost
was removed, suggesting the read lock on fqn_class_index (contended
by 32 workers doing find_or_load_class concurrently) is closer to the
real ceiling than the hashing was.
P48. Higher-order collection proxy injection repeats work¶
Impact: Low · Complexity: Medium
Grafting the item type's members onto a
HigherOrderCollectionProxy<TKey, TValue, 'method', Collection> runs
inside resolve_class_fully_with_generics, on the generic-substitution
path. Two avoidable costs sit there. Neither is a correctness problem
and both are linear rather than exponential, which is why they were left
when the feature landed; the outer (FQN, generic_args) cache absorbs
most of the repetition.
-
The value type is resolved twice. The framework annotates the proxy
@mixin \Illuminate\Support\Enumerable<TKey, TValue>and@mixin TValue. The second is a template-parameter mixin, so the "Template-param mixin resolution" block intype_engine/types/resolution.rsresolves the value class and merges its members afterinject_higher_order_proxy_membershas already grafted the same members with their proxied types. The injected members win (merge_virtual_memberskeeps whichever arrived first), so the second pass is pure waste. Skipping the template-param mixin for a tagged proxy, or letting the injection markTValueas already consumed, avoids it. -
Grafted members are not interned.
inject_higher_order_proxy_membersbuilds eachMethodInfo/PropertyInfodirectly, where every other transform site in the codebase goes throughintern_transformed_method/intern_transformed_propertyso that applying the same transform to the same origin shares oneArc. The proxied members of one item type are byte-identical across every proxy that wraps it with the same result shape, so the same model's members are re-allocated once per distinct(proxied method, owning collection)pair rather than shared.
Where to look: virtual_members/laravel/higher_order_proxy.rs,
virtual_members/resolve.rs, and the template-param mixin block in
type_engine/types/resolution.rs.
P49. A very long method chain costs superlinear time to analyse¶
Impact: Low · Complexity: Medium
Resolving a receiver spine no longer recurses per link, so a fluent chain of any length parses, hovers, and analyses without overflowing the stack. The work is still superlinear in the chain's length, though: a 1000-link chain takes roughly eight times as long to run diagnostics over as a 500-link one on a debug build, so a generated query builder or generated API client long enough turns a diagnostic pass into a multi-second stall.
Two costs compound along the spine, each linear in the prefix and paid once per link:
-
Subject text is rebuilt per link.
extract_call_exprcallsexpr_to_subject_text(method_call.object)for every link, which renders the whole prefix, so the symbol map spends O(n²) bytes on one chain. Rendering the spine once and handing each link a slice of the result would make it linear. -
Chain cache keys are rebuilt per link.
chain_cache_keycallsSubjectExpr::to_subject_text, which renders the whole prefix. A spine the chain cache answers at its outermost link only pays for one key, but a cold spine, or any resolution running without the cache active, needs a key per link and so renders O(n²) bytes. The keys of a spine are prefixes of one another, so one render plus per-link lengths would do.
Where to look: symbol_map/extraction/expressions/calls.rs for the
first, chain_cache_key in type_engine/resolver/mod.rs for the second.
Neither is a correctness problem, and hand-written code never reaches the
lengths where it shows.
P50. Cache the top-level scope for global keyword resolution¶
Impact: Low-Medium · Complexity: High
Every resolve_variable_types call on a file containing global
rebuilds the top-level scope by forward-walking every top-level
statement with cursor_offset = u32::MAX. This is done once per
variable query, so hovering three variables in the same file walks the
top-level three times.
Re-entry guards (added to fix #327) prevent the walk from recursing unboundedly, but the repeated cost remains. The preferred shape is pre-compute-and-cache: build the top-level scope once per file version (keyed by content hash or pointer) and reuse it across queries within the same request cycle.
Where to look: resolve_variable_in_statements in
type_engine/variable/resolution.rs, the walk_top_level_for_globals
call. A per-request cache (similar to the chain resolution cache in
type_engine/resolver/context.rs) would eliminate the redundant walks.
P51. CI-gated scaling and memory invariants¶
Impact: Medium · Complexity: Low-Medium
.github/workflows/ci.yml's benchmark/benchmark-pr jobs only run
the completion bench and publish it to the tracking dashboard; a
regression is visible after the fact (someone has to look at the
dashboard) rather than failing the PR. references.rs and
laravel_completion.rs under benches/ exist but aren't wired into
CI at all, and none of our benchmarks assert an absolute ceiling —
only relative-to-history comparison.
Add CI-gated invariants that fail the build outright when crossed, not just recorded for later inspection:
- Per-edit republish scaling. A synthetic workspace ingested at several sizes (e.g. 100 → 5000 files); assert republish-after-edit wall time stays flat (or sub-linear) as file count grows, catching an accidental O(n) or worse regression on the republish path before it ships.
- Cold/warm start wall time. Time to
indexReadyon a pinned fixture workspace (a vendored copy of a real Laravel/Symfony project, or one of the public corpora already used byanalyzetriage), gated on an absolute ceiling, both cold (no cache) and warm (repeat run). - Session RSS guard.
benches/memory_usage.pyalready measures resident memory on two workloads; run it in CI and fail if RSS exceeds a fixed ceiling instead of only exposing the script for manual use.
Where to look: .github/workflows/ci.yml's benchmark/
benchmark-pr jobs; benches/memory_usage.py; benches/references.rs
and benches/laravel_completion.rs for benches that exist but aren't
CI-gated yet.
P52. The diagnostic benchmarks measure a path no consumer takes¶
Impact: Medium · Complexity: Low
bench_diagnostics_phpactor_fixtures in benches/completion.rs calls
four collectors directly:
backend.collect_deprecated_diagnostics(&uri, content, &mut out);
backend.collect_unused_import_diagnostics(&uri, content, &mut out);
backend.collect_unknown_class_diagnostics(&uri, content, &mut out);
backend.collect_unknown_member_diagnostics(&uri, content, &mut out);
No consumer does this. Every real caller goes through
collect_slow_diagnostics_observed, which first activates the chain
resolution cache, the type-engine caches, and the forward-walked
diagnostic scope cache, then runs the collectors in an order chosen so
later ones read what earlier ones cached. The benchmark activates none
of them and runs deprecated_usage first, cold, where production runs
it last against warm caches.
The result is a tracked number that moves for reasons users never
experience. On the method_chain fixture the benchmark reports roughly
twice the time the production pass takes on the same file while running
a quarter of the collectors, and an optimisation to the cached path
shows up as a fraction of its real effect: making chain cache keys lazy
measured -25% on the production pass and -3.7% here, because without an
active cache the probe never hits early and every key is needed anyway.
Point the benchmark at collect_slow_diagnostics, so it measures what
an editor keystroke and an analyze run actually pay for. This resets
the tracked history for diagnostics/fixture/* once, which is worth it
for a number that tracks the real path. Keep a separate uncached case
only if there is a consumer that runs collectors without the guards.
Where to look: bench_diagnostics_phpactor_fixtures in
benches/completion.rs; collect_slow_diagnostics_observed in
diagnostics/mod.rs for the guards and the collector order.
P53. The deprecated collector deep-copies a class per member access¶
Impact: Medium · Complexity: Low
collect_deprecated_diagnostics resolves each member access to a class
and then clones the whole ClassInfo out of the Arc it just got:
resolve_variable_subject does the same on its own path, and the
per-variable cache stores Option<ClassInfo> rather than
Option<Arc<ClassInfo>>, so its hits clone too. The class is only ever
read afterwards (get_method, get_property, and a &ClassInfo
argument to resolve_class_fully_cached), so every one of those copies
is wasted, and the cost scales with the class's member count: a file
whose accesses land on a large resolved class (an Eloquent Builder, a
facade's concrete binding) pays for a full copy of its methods,
properties, and constants once per access.
Hold Arc<ClassInfo> through the collector instead. Both producers
already have one, deref coercion covers the read sites, and the
enclosing_class clone a few lines below is the same pattern.
Where to look: collect_deprecated_diagnostics and
resolve_variable_subject in diagnostics/deprecated.rs, plus the
var_type_cache declaration at the top of the collector.