Skip to content

Commit 9092234

Browse files
committed
Improve diagnostics multi threading
1 parent f99fb2b commit 9092234

13 files changed

Lines changed: 472 additions & 42 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4949
- **Lower memory use for member access spans.** The subject text recorded for every `->`/`::` access (e.g. `$this` in `$this->save()`) no longer allocates a string when it is a plain slice of the source, which covers the vast majority of accesses in typical PHP code. It now reuses the file's own bytes instead, with an allocation only for the rarer cases (chained calls, `new` expressions) where the recorded text differs from the source.
5050
- **Faster diagnostics on large projects.** Several diagnostic checks (by-reference parameter detection, the `Stringable`-to-`string` acceptance check, and `model-property<Model>` literal validation) now read a class's inheritance from the resolved-class cache instead of re-merging traits, parent classes, and generics from scratch on every call. On large Laravel projects this removed a measurable share of the diagnostic pass's CPU time, and as a side effect these checks now also see interface-declared members (e.g. a `__toString` declared only on an implemented interface).
5151
- **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.
52+
- **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.
5253

5354
### Removed
5455

docs/todo/performance.md

Lines changed: 113 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -895,7 +895,7 @@ current single populate thread.
895895

896896
Measured on a 32-core machine against large Laravel projects (release
897897
build): the `analyze` Phase 2 diagnostic pass spawns one worker per
898-
core with atomic work-stealing but does not keep them busy. Two
898+
core with atomic work-stealing but does not keep them busy. Three
899899
offenders are fixed. The original one was every worker deep-cloning
900900
the embedded stub class/function/constant indexes twice per file via
901901
`clone_for_diagnostic_worker`; the indexes are Arc-shared now. The
@@ -909,45 +909,126 @@ gitignore-aware walk. They are guarded by
909909
Laravel project from 11.6 to 22.6 of 32 cores (1.91 s → 1.31 s) and
910910
whole-run wall clock down ~15%.
911911

912+
The third was the name → class loader itself. `find_or_load_class_typed`
913+
was called 3.68 M times in a 1.2 s Phase 2 over a few thousand distinct
914+
types, and every call hashed the name case-insensitively for a read
915+
lock on `class_not_found_cache` and another on `fqn_class_index`. That
916+
cluster (`find_or_load_class_typed`, `find_class_in_uri_classes_index`,
917+
`CiMap::get`, `sip::Hasher::write`, `RawRwLock::lock_shared_slow`,
918+
kernel `osq_lock`) was ~22% of Phase 2 samples, and both lock symbols
919+
have since dropped out of the profile entirely. `class_loader_memo`
920+
memoises the loader per worker on the interned `PhpType` handle, keyed
921+
by `SymbolIndex::id` and stamped with `class_lookup_generation` so an
922+
answer is never staler than the two caches it derives from. Phase 2 fell
923+
~25-32% (1.23 s → 0.93 s and 0.72 s → 0.49 s on the two largest Laravel
924+
projects benchmarked) at ~26.8 → ~28.6 of 32 cores, with whole-run wall
925+
clock down 8-12% and user CPU down 17-34% across three large Laravel
926+
projects. Every smaller project benchmarked improved as well, RSS did
927+
not move, and diagnostic output was byte-identical on all ten.
928+
912929
Sampling `/proc/<pid>/task/*/stat` is the fastest way to see the
913930
remaining ceiling: workers in `S` rather than `R` are blocked, not
914-
computing. After the stampede fix the remaining gap is:
915-
916-
- ~18% of worker on-CPU time is malloc/free/memmove, diffuse:
917-
`PhpType`, `MethodInfo`, and `ParameterInfo` clones throughout the
918-
type engine, plus allocator arena contention when 32 workers
919-
allocate heavily.
920-
- The `PhpType` interner (`php_type::intern`) is hit ~13 M times in a
921-
1.3 s Phase 2, roughly a quarter of which fall through the shard
922-
read lock to the write path. Its 64 shards are the largest single
923-
source of measured lock-wait time left (~5 s of thread-time across
924-
all workers), and `sched_yield` volume shows the workers spinning
925-
before parking.
931+
computing. What is left, re-profiled after the memo:
932+
933+
- Type strings are still parsed during the diagnostic pass rather than
934+
at index time: `TypeTokenStream::fill_buffer_slow`, `PhpType::parse`,
935+
`LocalArena::alloc_slice_copy` and `parse_primary_type` together are
936+
~6.5% of Phase 2 samples. This is P14's territory.
937+
- malloc/free/memmove remains diffuse. `is_scalar_name` and
938+
`is_keyword_type` (~2.3% between them) allocate a lowercase `String`
939+
per call and are reached from `base_name`, the subtype checks and the
940+
narrowing paths; the ~150 other `to_ascii_lowercase()` calls in
941+
`php_type/` do the same. A stack buffer plus a length pre-filter
942+
would make them allocation-free, but see the reverted attempt below
943+
before assuming that wins.
944+
- The `PhpType` interner (`php_type::intern` + `intern::lookup`) is
945+
~4.6% of Phase 2 samples and its 64 shards are now the largest
946+
remaining lock, though the memo removed enough traffic that
947+
`lock_shared_slow` no longer registers at all. An earlier count put
948+
the interner at ~13 M hits per pass with roughly a quarter falling
949+
through the shard read lock to the write path; that has not been
950+
re-counted since. A per-thread direct-mapped memo in front of the
951+
shard read (the same shape as `class_loader_memo`) is the obvious
952+
next attempt.
926953
- `ensure_workspace_indexed_with_progress` still re-walks the whole
927954
workspace on every call, so the four surviving string-key
928955
enumerations do four full walks per diagnostic pass (down from ~44).
929956
The walk is deliberate — it is how PHP files created outside the
930957
editor get discovered — so removing it needs a change to that
931958
contract, not just another cache.
932-
- Projects that keep substantial code in `vendor/` (e.g. models shipped
933-
in a shared vendor package) pay extra: eager population only walks
934-
classes from the indexed user files, so every vendor class is parsed
935-
lazily on first touch during the diagnostic pass, serialising workers
936-
on the load path early in the run. Measured on a large Laravel
937-
project (release build, 32 threads): eager population covers only
938-
~1,300 user classes, and the diagnostic pass then resolves ~2,100
939-
further classes lazily, with cycle-break re-merges that eager
940-
dependency-first ordering would have avoided (eager population
941-
itself hit only 3).
942-
943-
Likely next steps, in rough value order: reduce clone traffic in the
944-
hot type-engine paths (share via `Arc`/`Cow` instead of cloning
945-
`PhpType`/member vectors), cut interner traffic or widen its sharding,
946-
include depended-upon vendor classes in eager population, and only
947-
then consider allocator-level fixes. The LSP workspace diagnostics
948-
pass uses the same collectors and has the same ceiling. Re-measure
949-
with `perf` (frame-pointer build) or the CPU-sampling loop in the
950-
Appendix after any change.
959+
- `class_loader_memo`'s own hit rate is bounded by how often
960+
`note_class_lookup_change` fires: 1,169 times in Phase 2, once per
961+
lazily parsed vendor file, each retiring every worker's table. A
962+
build with invalidation removed (unsound, for sizing only) reached
963+
0.80 s against the 0.93 s shipped, so ~14% of the memo's prize is
964+
still on the table. Recovering it means either loading fewer vendor
965+
classes lazily (the reverted experiment below, whose calculus this
966+
changes) or splitting the generation so an additive insert only
967+
retires negative answers. The latter is not sound as stated: a
968+
positive answer reached through PSR-4 or a stub is matched by short
969+
name, so it is not always backed by an `fqn_class_index` entry under
970+
the name that was looked up, and a later first-time insert of that
971+
exact name can change it.
972+
973+
The LSP workspace diagnostics pass uses the same collectors and has the
974+
same ceiling. Re-measure with `perf` (frame-pointer build) or the
975+
CPU-sampling loop in the Appendix after any change.
976+
977+
**Tried and reverted (vendor classes in eager population):** an
978+
earlier revision of this item claimed that projects keeping
979+
substantial code in `vendor/` pay extra because eager population only
980+
walks the indexed user files, leaving every vendor class to be parsed
981+
and resolved lazily mid-diagnostic — serialising workers on the load
982+
path, with cycle-break re-merges that dependency-first ordering would
983+
have avoided. Seeding eager population with vendor classes was
984+
implemented in two escalating variants and benchmarked (10-run,
985+
order-swapped wall/user-CPU averages against the two largest Laravel
986+
projects benchmarked): (1) expanding the toposort input with the
987+
transitive inheritance closure of the user classes (parents, traits,
988+
interfaces, mixins, generic arguments, loaded via
989+
`find_or_load_class`), and (2) additionally seeding every `use`-import
990+
target found in `fqn_uri_index`, with parallel frontier loading and
991+
Kahn-levelled parallel resolution to keep Phase 1.5 off the critical
992+
path. Neither moved wall clock on any project. Variant 1 cut lazy
993+
Phase 2 resolutions only ~2% (950 → 931) because vendor ancestors
994+
were already being resolved as nested resolutions during eager
995+
population; variant 2 cut them to a third (931 → 276) but cost ~3%
996+
more user CPU (duplicated nested provider resolutions across level
997+
workers) and ~10 MB RSS for classes the pass never needed resolved.
998+
The predicted cycle-break re-merges also failed to reproduce: Phase 2
999+
hits 0 on one of them and 18 on the other, and all
1000+
18 are genuine dependency cycles (`Schedule`
1001+
`PendingEventAttributes`, Spatie `Role``Permission`) that
1002+
dependency-first ordering cannot avoid — the toposort has to break
1003+
them somewhere too. Conclusion: after the stampede fix, lazy vendor
1004+
class loading no longer serialises the diagnostic pass measurably;
1005+
the remaining ceiling is the clone/interner traffic above. Diagnostic
1006+
output was byte-identical in every configuration.
1007+
1008+
**Tried and reverted:** a single `perf` snapshot attributed ~4% of
1009+
Phase 2 time to `core::hash::sip::Hasher::write`, called from
1010+
`CiMap`/`CiSet` (`fqn_class_index`, `class_not_found_cache`) inside
1011+
`find_or_load_class` — the single hottest function in the profile.
1012+
Two independent fixes were tried: swapping `CiMap`/`CiSet`'s `HashMap`
1013+
from `std`'s default SipHash to a hand-rolled FxHash-style hasher, and
1014+
avoiding `fold()`'s per-lookup heap allocation with a stack buffer.
1015+
Both looked sound in isolation and passed all tests, but 10-run
1016+
wall-clock/user-CPU averages against a large Laravel project (order-swapped
1017+
to rule out warm-cache bias) showed a small, consistent *regression*
1018+
(~2% more user CPU) for the hasher swap alone, the allocation-avoidance
1019+
alone, and the two combined. Likely cause: `mimalloc` already makes
1020+
these transient allocations cheap, and the hand-rolled hasher's
1021+
sequential dependency chain (`rotate_left``xor``wrapping_mul` per
1022+
word) didn't beat `std`'s SipHash13 for these short keys on this
1023+
hardware. Lesson for next attempt: a single `perf --stdio` percentage
1024+
is not sufficient evidence — confirm with repeated, order-controlled
1025+
wall-clock measurement before committing a "hot function" fix; sampling
1026+
noise and inlining attribution can point at the wrong function. The
1027+
re-measurement did surface a more promising lead: `RawRwLock::lock_shared_slow`
1028+
rose from 3.78% to 4.36% of samples once the hashing/allocation cost
1029+
was removed, suggesting the read lock on `fqn_class_index` (contended
1030+
by 32 workers doing `find_or_load_class` concurrently) is closer to the
1031+
real ceiling than the hashing was.
9511032

9521033
---
9531034

src/class_loader_memo.rs

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
//! Per-thread memo for [`Backend::find_or_load_class_typed`].
2+
//!
3+
//! [`Backend`]: crate::Backend
4+
//!
5+
//! The diagnostic pass asks for the same classes over and over: one
6+
//! `analyze` run on a large Laravel project makes ~3.7 M name → class
7+
//! lookups over a few thousand distinct types. Every one of them walked the
8+
//! multi-phase loader in `resolution.rs`, hashing the name
9+
//! case-insensitively for a read lock on `class_not_found_cache` and
10+
//! another on `fqn_class_index`. With one worker per core those two lock
11+
//! words were the pass's ceiling: the loader and the lock and hash code
12+
//! under it were ~22% of its samples, ~7% of that spent purely acquiring
13+
//! the read locks.
14+
//!
15+
//! Interned [`PhpType`] handles are a far cheaper key than the name: one
16+
//! multiply to index a slot, then pointer equality. A repeat lookup
17+
//! touches nothing but this thread's own table.
18+
//!
19+
//! # Freshness
20+
//!
21+
//! A slot is served only while its stamp matches
22+
//! [`SymbolIndex::class_lookup_generation`], which
23+
//! [`SymbolIndex::note_class_lookup_change`] bumps on every mutation of
24+
//! `fqn_class_index` and every clear of `class_not_found_cache`. Those are
25+
//! the two structures a loader answer is derived from, so a memoised
26+
//! answer is never staler than the caches it came from. Retired slots are
27+
//! overwritten in place rather than swept, so invalidation costs nothing.
28+
//!
29+
//! [`SymbolIndex::class_lookup_generation`]: crate::symbol_index::SymbolIndex::class_lookup_generation
30+
//! [`SymbolIndex::note_class_lookup_change`]: crate::symbol_index::SymbolIndex::note_class_lookup_change
31+
//!
32+
//! # Why a fixed table
33+
//!
34+
//! Memory is bounded with no eviction policy to tune: `SLOTS` entries per
35+
//! thread that ever looks a class up, holding one type handle and one
36+
//! `Arc<ClassInfo>` each — both already kept alive by the interner and the
37+
//! class index. A long-lived server cannot grow it, and a collision costs
38+
//! one recomputation.
39+
//!
40+
//! # Why per-thread
41+
//!
42+
//! A shared table would reintroduce the lock this memo exists to avoid.
43+
//! Threads are not the unit of ownership, though, so each slot records
44+
//! which [`SymbolIndex`] produced it: a thread reused across `Backend`s
45+
//! (nextest runs many in one process) cannot be served another project's
46+
//! classes.
47+
//!
48+
//! [`SymbolIndex`]: crate::symbol_index::SymbolIndex
49+
50+
use std::cell::RefCell;
51+
use std::sync::Arc;
52+
53+
use crate::php_type::PhpType;
54+
use crate::types::ClassInfo;
55+
56+
/// Slots per thread, a power of two so the index is a mask.
57+
///
58+
/// 8 KB per thread at 32 bytes a slot. Measured against 1024 on two large
59+
/// Laravel projects: the smaller table won slightly on both, so the working
60+
/// set of one file's diagnostics fits and the locality of a table that
61+
/// stays in L1 is worth more than the collisions avoided by a larger one.
62+
const SLOTS: usize = 256;
63+
64+
/// One memoised answer.
65+
struct Slot {
66+
/// The type this answer is for, or `None` in a slot never written.
67+
///
68+
/// Held as a handle rather than as the address it hashes to: dropping
69+
/// the last handle to a type frees the interned node, and a later type
70+
/// could be allocated at the same address and match a stale key.
71+
key: Option<PhpType>,
72+
/// The loader's answer, itself `None` for "no such class".
73+
class: Option<Arc<ClassInfo>>,
74+
/// Identity of the index that produced `class`.
75+
owner: u64,
76+
/// Index generation `class` was looked up at.
77+
generation: u64,
78+
}
79+
80+
impl Slot {
81+
const fn empty() -> Slot {
82+
Slot {
83+
key: None,
84+
class: None,
85+
owner: 0,
86+
generation: 0,
87+
}
88+
}
89+
}
90+
91+
thread_local! {
92+
/// Allocated on the first lookup, so threads that never resolve a
93+
/// class pay nothing for it.
94+
static TABLE: RefCell<Option<Box<[Slot]>>> = const { RefCell::new(None) };
95+
}
96+
97+
/// Slot for `ty`: Fibonacci hashing of the node address, whose low bits
98+
/// are always zero from the allocator's alignment.
99+
#[inline]
100+
fn slot_of(ty: &PhpType) -> usize {
101+
const PHI: u64 = 0x9e37_79b9_7f4a_7c15;
102+
((ty.identity() as u64).wrapping_mul(PHI) >> (64 - SLOTS.trailing_zeros())) as usize
103+
}
104+
105+
/// The memoised answer for `ty`, or `None` when there is none and the
106+
/// caller must run the loader and report back to [`store`].
107+
///
108+
/// The outer `Option` distinguishes "not memoised" from a memoised
109+
/// "no such class"; both are worth caching, since a negative answer costs
110+
/// the same multi-phase walk as a positive one.
111+
pub(crate) fn probe(owner: u64, generation: u64, ty: &PhpType) -> Option<Option<Arc<ClassInfo>>> {
112+
TABLE.with(|table| {
113+
let table = table.borrow();
114+
let slot = &table.as_ref()?[slot_of(ty)];
115+
if slot.owner != owner || slot.generation != generation {
116+
return None;
117+
}
118+
// Pointer equality: a different type hashing to this slot is a
119+
// miss, not a wrong answer.
120+
(slot.key.as_ref()? == ty).then(|| slot.class.clone())
121+
})
122+
}
123+
124+
/// Record `class` as the answer for `ty`, replacing whatever occupied the
125+
/// slot.
126+
///
127+
/// Called only after [`probe`] has returned, never with the loader still
128+
/// running: loading a class parses a file, which resolves further classes
129+
/// and re-enters this table.
130+
pub(crate) fn store(owner: u64, generation: u64, ty: &PhpType, class: &Option<Arc<ClassInfo>>) {
131+
TABLE.with(|table| {
132+
let mut table = table.borrow_mut();
133+
let table = table
134+
.get_or_insert_with(|| (0..SLOTS).map(|_| Slot::empty()).collect::<Vec<_>>().into());
135+
table[slot_of(ty)] = Slot {
136+
key: Some(ty.clone()),
137+
class: class.clone(),
138+
owner,
139+
generation,
140+
};
141+
});
142+
}
143+
144+
#[cfg(test)]
145+
#[path = "class_loader_memo_tests.rs"]
146+
mod tests;

0 commit comments

Comments
 (0)