Skip to content

Latest commit

 

History

History
842 lines (637 loc) · 34 KB

File metadata and controls

842 lines (637 loc) · 34 KB

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 effort (ascending) within the same impact tier.

Label Scale
Impact Critical, High, Medium-High, Medium, Low-Medium, Low
Effort Low (≤ 1 day), Medium (2-5 days), Medium-High (1-2 weeks), High (2-4 weeks), Very High (> 1 month)

P1. Reference-counted ClassInfo (Arc<ClassInfo>)

Impact: High · Effort: Medium

type_hint_to_classesVec<Arc<ClassInfo>>. type_hint_to_classes is the main bridge between the type-string world and the class-object world. It is called from both the call-resolution pipeline (which already returns Vec<Arc<ClassInfo>> and currently wraps each result with Arc::new) and the variable- resolution pipeline (which still operates on Vec<ClassInfo>). Changing type_hint_to_classes (and its recursive helper type_hint_to_classes_depth) to return Vec<Arc<ClassInfo>> would eliminate ~15 Arc::new wraps at call sites that were added during the call-resolution conversion, and would be a natural stepping stone if someone later decides to convert the variable-resolution pipeline.

The variable-resolution pipeline (resolve_variable_types, resolve_rhs_expression, check_expression_for_assignment, and ~30 helper functions) still operates on Vec<ClassInfo> internally. Converting it would eliminate ~29 deep clones at bridge sites in rhs_resolution.rs, foreach_resolution.rs, and closure_resolution.rs, but the cascade touches ~86 sites across the subsystem. The effort-to-impact ratio is poor: each eliminated clone saves one per-request copy, not a hot-loop copy, and the parent-chain walks in declaring.rs, inheritance.rs, and phpdoc.rs will always need Arc::unwrap_or_clone for mutation regardless.


P1.5. Layered class resolution (zero-copy inheritance)

Impact: High · Effort: Very High

Problem

resolve_class_with_inheritance builds a flat ClassInfo by cloning the base class and then copying every method, property, and constant from traits, parents, and interfaces into the result. For an Eloquent model this means deep-copying hundreds of MethodInfo structs (each containing String fields, Vec<ParameterInfo>, etc.). Even with SharedVec making the top-level Vec clone O(1), the individual MethodInfo clones during the merge are the single largest remaining allocation cost (~4 % of CPU in perf profiles).

The fundamental issue: the resolved class is a copy of all inherited members rather than a view over immutable originals.

Ideal architecture

Replace the flat merged ClassInfo with a layered view that references the originals without copying:

ResolvedClass {
    own:     Arc<ClassInfo>,                  // parsed, immutable
    traits:  Vec<Arc<ClassInfo>>,             // resolved traits
    parent:  Option<Arc<ResolvedClass>>,      // resolved parent (recursive)
    virtual: Vec<Arc<MethodInfo>>,            // @method, @mixin, scopes
    iface_fill: HashMap<String, TypeFillIn>,  // interface type enrichment
}

Member lookups walk the layers (own → traits → parent → virtual) instead of iterating a single flat Vec. Dedup is handled by a name-based HashSet built lazily or maintained incrementally.

Benefits:

  • Zero-copy inheritance. Moving a method from parent to child is an Arc::clone (refcount bump), not a MethodInfo deep clone.
  • Shared structure. Two child classes that extend the same parent share the parent's Arc<ResolvedClass> — the parent's methods exist in memory once, not once per child.
  • Cheaper cache invalidation. Editing a child class only rebuilds the child's layer; the parent layer stays cached.

Migration path

  1. Arc<MethodInfo> everywhere. Change SharedVec<MethodInfo> to SharedVec<Arc<MethodInfo>> (and same for properties/constants). This makes individual method clones O(1) within the existing flat architecture — an incremental win without changing consumers.

  2. Introduce ResolvedClass struct. Start with a thin wrapper that holds the flat ClassInfo inside, exposing the same iteration API. Consumers migrate incrementally.

  3. Layered storage. Replace the flat ClassInfo inside ResolvedClass with the layered structure. Change iteration to walk layers. This is the big step — every .methods.iter().find() call site needs to use the layered iterator.

  4. Lazy dedup index. Build a HashMap<&str, (Layer, usize)> on first access (or maintain it incrementally during layer construction) so that find_method("foo") is O(1) without scanning all layers.

Risks

  • Every consumer that does .methods.iter() or .methods.len() needs to work with the layered iterator. A Deref-based shim can ease migration but adds indirection.
  • Mutation sites (merge_traits_into, virtual member providers) need to operate on the layer structure rather than pushing into a flat Vec.
  • The resolved_class_cache currently stores Arc<ClassInfo>; it would store Arc<ResolvedClass> instead.

When to implement

This is the right long-term direction but a major refactor (~1 month). Evaluate after the Arc<MethodInfo> step (migration path step 1) is complete and profiling confirms that the flat-merge copy cost is still the dominant bottleneck.


P3. Parallel pre-filter in find_implementors

Impact: Medium · Effort: Medium

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:

  1. Parallel pre-filter. Collect the candidate paths into a Vec<PathBuf>, then use std::thread::scope to read files and run the raw.contains(target_short) check in parallel. Return only the paths that pass the filter along with their content.

  2. Sequential parse. For the (few) files that pass, call parse_and_cache_file sequentially. This step mutates ast_map and calls class_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.

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).


P4. memmem for block comment terminator search

Impact: Low-Medium · Effort: Low

The current block comment skip in find_classes and find_symbols uses memchr(b'*', ...) and then checks the next byte for /. This is effective but can false-match on * characters inside docblock annotations (e.g. @param, @return, starred lines). Each false match falls through to a single-byte advance, which is correct but suboptimal for large docblocks.

Fix

Replace memchr(b'*', ...) with memmem::find(content[i..], b"*/"). This searches for the two-byte sequence */ directly, skipping all intermediate * characters in a single SIMD pass. The memmem searcher is already imported and used for keyword pre-screening.

For typical PHP files this is a marginal improvement. For files with very large docblocks (e.g. generated API documentation classes with hundreds of @method tags), it avoids O(n) false * matches inside the comment body.


P5. memmap2 for file reads during scanning

Impact: Low-Medium · Effort: Low

All file-scanning paths (scan_files_parallel_classes, scan_files_parallel_psr4, scan_files_parallel_full, and the find_implementors pre-filter) use std::fs::read(path) which copies the entire file into a heap-allocated Vec<u8>. When the OS page cache already has the file mapped, memmap2 can provide a read-only view of the file's pages without any copy.

Fix

Add memmap2 as a dependency. In the parallel scan helpers, replace std::fs::read(path) with unsafe { Mmap::map(&file) }. The find_classes and find_symbols scanners already accept &[u8], so the change is confined to the call sites.

Safety

Memory-mapped reads are unsafe because another process could truncate the file while the map is live, causing a SIGBUS. In practice this does not happen during LSP initialization (the user is not deleting PHP files while the editor starts). A fallback to fs::read on map failure handles edge cases.

When to implement

Profile first. On Linux with a warm page cache the difference between read and mmap is small for files under ~100 KB (which covers most PHP files). The benefit is more pronounced on macOS where read involves an extra kernel-to-userspace copy. If profiling shows that file I/O is no longer the bottleneck after parallelisation, this item can be dropped.


P6. O(n²) transitive eviction in evict_fqn

Impact: Low-Medium · Effort: Low

The evict_fqn function in virtual_members/mod.rs runs a fixed-point loop that scans the entire resolved-class cache on each iteration to find transitive dependents. In a large project with a deep class hierarchy (common in Laravel codebases with hundreds of Eloquent models), editing a base class can trigger a cascade of evictions where each round does a full cache scan.

The depends_on_any helper also matches against both the FQN and the short name of the evicted class, which increases the chance of false-positive transitive evictions (e.g. two unrelated classes that share a short name like Builder).

Fix

Build a reverse-dependency index (HashMap<String, Vec<String>>) that maps each FQN to the set of cached FQNs that directly depend on it. Maintain this index alongside cache insertions and removals. On eviction, walk the reverse index instead of scanning the entire cache, turning the O(n²) loop into O(dependents).

If the reverse index is too much bookkeeping, a simpler first step is to collect all dependents in a single pass (instead of the current iterative fixed-point loop) by doing a breadth-first walk of the dependency graph within the cache.


P7. diag_pending_uris uses Vec::contains for deduplication

Impact: Low · Effort: Low

schedule_diagnostics and schedule_diagnostics_for_open_files deduplicate pending URIs with Vec::contains, which is O(n) per insertion. When a class signature changes, every open file is queued, and each insertion scans the entire pending list.

For typical usage (< 50 open files) this is imperceptible. It becomes measurable only with hundreds of open tabs and rapid cross-file edits.

Fix

Replace Vec<String> with IndexSet<String> (from indexmap) or HashSet<String> + a separate Vec<String> for ordering. The worker drains the collection on each wake, so insertion order is not important and a plain HashSet suffices.


P8. find_class_in_ast_map linear fallback scan

Impact: Low · Effort: Low

The fast O(1) fqn_index lookup in find_class_in_ast_map covers the common case. The slow fallback iterates every file in ast_map linearly. The comment says this covers "race conditions during initial indexing" and anonymous classes.

During initial indexing with many files open, the fallback could cause micro-stutters if the fqn_index has not been populated yet for a requested class. In steady state the fallback is rarely hit.

Fix

Audit the code paths that can reach the fallback to determine whether they are still reachable after the fqn_index was added. If they are not, replace the fallback with a None return and a debug log. If they are, consider populating fqn_index earlier in the pipeline (e.g. during the byte-level scan phase) to close the window.


P9. resolved_class_cache generic-arg specialisation

Impact: Medium · Effort: Medium

The resolved-class cache is keyed by (FQN, Vec<String>). Every distinct generic instantiation of the same class (e.g. Builder<User>, Builder<Order>, Builder<Product>) triggers a full resolve_class_fully call, even though the base resolution (inheritance merging, trait merging, virtual member injection) is identical. Only the final generic substitution differs.

In a Laravel codebase with hundreds of Eloquent models, this means Builder is fully resolved hundreds of times, once per model.

Fix

Cache the base-resolved class (before generic substitution) separately, keyed by FQN alone. When a generic instantiation is requested, look up the base-resolved class and apply apply_substitution on top. The substitution step is cheap (tree walk) compared to the full resolution (inheritance walking, trait merging, virtual member providers).

This requires splitting resolve_class_fully into two stages: base resolution (cached by FQN) and generic specialisation (cached by (FQN, Vec<String>) as today, but with a much cheaper miss path).


P10. Redundant parse_and_cache_file from multiple threads

Impact: Medium · Effort: Low

When two threads simultaneously try to resolve the same vendor class, both miss fqn_index, both call parse_and_cache_file, and both parse the same file. The second parse is wasted work. This is most visible during the Phase 2 diagnostic pass when many threads resolve vendor classes for the first time.

Fix

Add a DashSet<String> (or similar) of "currently being parsed" URIs. Before calling parse_and_cache_file, insert the URI into the set. If the insert fails (another thread is already parsing it), spin-wait or skip and let the other thread's result propagate through fqn_index. Remove the URI from the set after parsing completes.


P11. Uncached base-resolution in build_scope_methods_for_builder

Impact: Low-Medium · Effort: Low

build_scope_methods_for_builder calls resolve_class_with_inheritance (base resolution) for the model class. This is not covered by the thread-local resolved-class cache, which stores fully-resolved classes (after virtual member injection), not base-resolved ones.

Every time an Eloquent Builder<Model> is resolved with scope injection, the model is base-resolved from scratch. With many Builder instantiations in a single file this adds up.

Fix

Either introduce a separate base-resolution cache (keyed by FQN), or restructure so build_scope_methods_for_builder accepts the already-resolved model class from the caller (which may already have it from the resolved-class cache).


P12. find_or_load_function Phase 1.75 serial bottleneck

Impact: Low · Effort: Low

For the first unknown function that misses both global_functions and autoload_function_index, Phase 1.75 iterates all known autoload file paths and calls update_ast on each unparsed one until the function is found. With ~50 autoload files this is a one-time cost per thread, but it blocks the thread while it happens.

Fix

Pre-load all autoload files during initialisation (in the initialized handler, after the byte-level scan). This moves the cost to startup, where it can run in parallel with other init work, and eliminates the blocking fallback during interactive use.


P13. Tiered storage: drop per-file maps for non-open files

Impact: Medium-High · Effort: Medium-High

Note. This item needs refinement when we work on it. The codebase and feature set may change significantly before then.

Not every file needs the same data at runtime. Storage should be split into three tiers based on how the file is used:

Data Open files Closed user files Vendor files
ClassInfo (full) keep keep (via fqn_index) keep (via fqn_index)
SymbolMap keep drop (on-demand for find-refs) never
use_map keep drop after index drop after index
namespace_map keep drop after index drop after index
parse_errors keep never never
ast_map entry keep drop (redundant with fqn_index) drop
fqn_index keep keep keep
class_index keep keep keep
GTI index (new) keep keep keep

Key observations:

  • SymbolMap is the biggest win. Each SymbolMap stores a SymbolSpan for every symbol reference in the file, plus VarDefSite, CallSite, and scope data. A typical file with 100-500 symbols is several KB. Across thousands of files this adds up to tens or hundreds of MB.

  • ast_map entries are redundant with fqn_index once indexing is complete. The slow linear fallback in find_class_in_ast_map should not fire when fqn_index is fully populated. Go-to-definition can re-parse on demand using the file path from class_index.

  • Vendor files are rarely edited but can be diagnosed. Users working in monorepos or with --prefer-source packages edit vendor files directly, and diagnostics run on any file open in the editor. Tiered storage must still keep enough data to support diagnostics for open vendor files, but non-open vendor files only need ClassInfo for type resolution and class_index for go-to-definition file lookup.

  • Go-to-implementation currently scans all ast_map entries. A dedicated GTI index (parent FQN to list of child FQNs, built during indexing) would decouple it from ast_map and allow ast_map entries for non-open files to be dropped without breaking implementation search. GTI needs vendor data (to find chains through vendor classes) but only the parent/child relationship, not the full per-file maps.

  • Find-references only needs SymbolMaps for user code. These could be built on demand (parse, scan, drop) rather than kept resident.

  • Analyse mode benefits from laziness. It never loads vendor files that are not referenced by any user chain. LSP mode with full vendor indexing would load everything since it cannot predict what the user will type next. This makes the tiered cleanup more important for LSP than for analyse.

Implementation sketch

  1. Track which URIs are "open" (already done via open_files).
  2. On did_close, drop the SymbolMap, use_map, namespace_map, parse_errors, and ast_map entries for that URI. The fqn_index entry (Arc<ClassInfo>) stays.
  3. For vendor files, use parse_and_cache_content (not update_ast) so SymbolMaps are never created. After indexing, sweep vendor URIs out of ast_map/use_map/namespace_map.
  4. Build a dedicated GTI index during indexing so that find_implementors does not need ast_map.
  5. For find-references, build SymbolMaps on demand by re-parsing from disk.

P14. Eager docblock parsing into structured fields

Impact: Medium · Effort: Medium

Note. This item needs refinement when we work on it. The codebase and feature set may change significantly before then.

Currently ClassInfo::class_docblock stores the raw docblock string. Every consumer that needs virtual members (@method, @property, @property-read, @property-write) re-parses the raw text via PHPDocProvider. Hover, completion, and diagnostics all trigger this independently.

Parse the class-level docblock once during extraction and store the structured results directly on ClassInfo:

  • A list of parsed @method signatures (name, parameters, return type, static flag, description).
  • A list of parsed @property / @property-read / @property-write entries (name, type, access mode, description).

This has three benefits:

  1. Drop the raw string. For heavily-annotated classes (Eloquent models, facades) the raw docblock can be hundreds of bytes. The structured representation may be comparable in size but is directly usable without re-parsing.

  2. Eliminate repeated parsing. Virtual member resolution currently re-parses the same docblock text on every completion, hover, and diagnostic pass. Parsing once during extraction removes this redundant work.

  3. Simpler consumer code. Consumers iterate structured fields instead of calling into the docblock parser. This removes the lazy-parse indirection and makes the data flow easier to follow.

The same principle applies to other docblock data that is currently extracted from raw text at multiple read sites (descriptions, link URLs, see references), though those are smaller wins.


P15. Two-phase stub index construction (eliminate RwLock on stub maps)

Impact: Low · Effort: Medium

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(…). Every subsequent 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:

  1. Phase 1 — skeleton construction. Create the Backend with empty (or placeholder) stub maps. No RwLock needed because nothing reads them yet.

  2. Phase 2 — version-aware population. In initialized, after detecting the PHP version, build the filtered maps (applying is_stub_function_removed / is_stub_class_removed during construction rather than via retain) 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 (e.g. P13 tiered storage).


P16. Pre-parsed stub format (eliminate raw PHP embedding)

Impact: High · Effort: Medium-High

The 630 phpstorm-stubs PHP files are embedded as raw source via include_str! (~9.8 MB in .rodata). This has three costs:

  1. 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.

  2. Parse cost on first access. Each stub is parsed with the full mago parser on first use (parse_and_cache_content_versioned). Large files like intl.php (296 KB) take several milliseconds. A Symfony project can trigger hundreds of stub parses as vendor classes extend built-in types.

  3. Duplicate data. After parsing, the Arc<ClassInfo> lives in ast_map and fqn_index, but the raw PHP source stays resident in .rodata forever. Both copies exist simultaneously.

Indexing order: stubs → vendor → user

Background indexing will load data in dependency order:

  1. Stubs (built-in PHP classes, functions, constants)
  2. Vendor (Composer dependencies)
  3. 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, uses Traversable, 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.php for ArrayAccess also parses Iterator, 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_FILES array (raw PHP source embedding)
  • The phpantom-stub:// URI scheme and associated ast_map entries
  • The parse_and_cache_content_versioned path for stubs
  • The is_stub_function_removed / is_stub_class_removed byte scanners (replaced by version fields on deserialized structs)
  • The set_php_version retain-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 .rodata no 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 ClassInfo is already in fqn_index
  • Build time: clean builds gain 10-30 s for the mago parse step; incremental builds unaffected (write_if_changed caching)

Prerequisites

  • serde derive on the core type hierarchy (already in Cargo.toml)
  • build.rs already downloads stubs and generates code; extending it to parse PHP is incremental
  • Interacts with P15 (stub index RwLock elimination): 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 · Effort: Low

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 currently consumed only by diagnostics (which run asynchronously) and FileContext::resolve_name_at(). Nothing on the completion hot path requires this data to be computed eagerly.

Fix

Defer name resolution out of update_ast_inner. Options:

  • Lazy resolution: compute OwnedResolvedNames on first access per file version, invalidate on the next update_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 · Effort: Low

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

  1. Add a thread-local or per-request HashMap<(String, String), 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.

  2. Add a has_template_params: bool flag (or equivalent) to ClassInfo or type representations. Set it during parsing when @template tags or generic syntax are present. Before running apply_substitution, check the flag and skip the substitution walk entirely when it is false.

  3. Intern class name strings. PHPantom creates many copies of the same class name (e.g. "Illuminate\\Database\\Eloquent\\Builder") across ClassInfo, type strings, and lookup keys. Mago already uses Atom (an interned string type) in its crates, and names flowing through mago-names / mago-syntax are already atoms. Using Atom or Arc<str> for class names in PHPantom's own data structures would reduce memory and make the subtype cache keys cheaper to hash and compare. Now that PhpType is the structured type representation throughout the codebase, interning the name strings inside each PhpType node (replacing owned String with Atom or Arc<str>) is a natural next step.


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

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]