Skip to content

Commit f99fb2b

Browse files
committed
Laravel string keys are collected once instead of once per CPU core
1 parent 55dde0b commit f99fb2b

6 files changed

Lines changed: 209 additions & 67 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4848
- **Lower memory use for method lookups.** Each resolved class's method name index is now a sorted list searched with binary search instead of a hash map, using about a third of the memory for the same lookup speed. On large Laravel projects, where the resolved-class cache holds thousands of these indexes, this measurably shrinks total memory use.
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).
51+
- **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.
5152

5253
### Removed
5354

docs/todo.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ unlikely to move the needle for most users.
197197
| | **[Performance](todo/performance.md)** | | |
198198
| P29 | [Migrate to `mago-phpdoc-syntax`](todo/performance.md#p29-migrate-to-mago-phpdoc-syntax) (drop deprecated `mago-docblock` / `mago-type-syntax`) | Medium | Medium |
199199
| P30 | [Evaluate migrating parse/resolve/docblock pipeline to `mago-hir`](todo/performance.md#p30-evaluate-migrating-parseresolvedocblock-pipeline-to-mago-hir) (blocked on upstream API stabilizing — see triggers) | Medium-High | High |
200+
| P43 | [`init_single_project` is the longest single-threaded stretch of a run](todo/performance.md#p43-init_single_project-is-the-longest-single-threaded-stretch-of-a-run) | Medium-High | Medium |
200201
| P16 | [Pre-parsed stub format (eliminate raw PHP embedding)](todo/performance.md#p16-pre-parsed-stub-format-eliminate-raw-php-embedding) | High | Medium-High |
201202
| P25 | [`type_mismatch_argument` / `argument_count_mismatch` slow on large single files](todo/performance.md#p25-type_mismatch_argument-argument_count_mismatch-slow-on-large-single-files) | Medium | Medium |
202203
| P22 | [Signature change re-queues slow diagnostics for every open file](todo/performance.md#p22-signature-change-re-queues-slow-diagnostics-for-every-open-file) | Medium-High | Medium |

docs/todo/performance.md

Lines changed: 75 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -894,39 +894,60 @@ current single populate thread.
894894
**Impact: Medium-High · Effort: Medium**
895895

896896
Measured on a 32-core machine against large Laravel projects (release
897-
build): the `analyze` Phase 2 diagnostic pass peaks around 14 cores and
898-
averages fewer, despite spawning one worker per core with atomic
899-
work-stealing. The original worst offender (every worker deep-cloning
897+
build): the `analyze` Phase 2 diagnostic pass spawns one worker per
898+
core with atomic work-stealing but does not keep them busy. Two
899+
offenders are fixed. The original one was every worker deep-cloning
900900
the embedded stub class/function/constant indexes twice per file via
901-
`clone_for_diagnostic_worker`) is fixed; the indexes are Arc-shared
902-
now. The remaining ceiling, per a frame-pointer `perf` profile of the
903-
diag workers after that fix:
904-
905-
- ~18% of worker on-CPU time is still malloc/free/memmove, now diffuse:
901+
`clone_for_diagnostic_worker`; the indexes are Arc-shared now. The
902+
second was the Laravel string-key enumerations
903+
(`cached_route_names`/`cached_config_keys`/`cached_view_names`/
904+
`cached_trans_keys`/`cached_config_trees`): each walks the workspace
905+
from disk, and the plain check-then-fill cache stampeded, so all 32
906+
workers missed the same empty slot at once and each repeated the same
907+
gitignore-aware walk. They are guarded by
908+
`LaravelStringKeyBuildLocks` now, which took Phase 2 on a large
909+
Laravel project from 11.6 to 22.6 of 32 cores (1.91 s → 1.31 s) and
910+
whole-run wall clock down ~15%.
911+
912+
Sampling `/proc/<pid>/task/*/stat` is the fastest way to see the
913+
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:
906917
`PhpType`, `MethodInfo`, and `ParameterInfo` clones throughout the
907-
type engine, plus glibc malloc arena contention (visible as
908-
`arena_get2`/futex kernel time) when 32 workers allocate heavily.
909-
- Read-lock contention on `fqn_class_index` and `class_not_found_cache`
910-
is measurable but small (~1.5% on-CPU in the rwlock slow paths).
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.
926+
- `ensure_workspace_indexed_with_progress` still re-walks the whole
927+
workspace on every call, so the four surviving string-key
928+
enumerations do four full walks per diagnostic pass (down from ~44).
929+
The walk is deliberate — it is how PHP files created outside the
930+
editor get discovered — so removing it needs a change to that
931+
contract, not just another cache.
911932
- Projects that keep substantial code in `vendor/` (e.g. models shipped
912933
in a shared vendor package) pay extra: eager population only walks
913934
classes from the indexed user files, so every vendor class is parsed
914935
lazily on first touch during the diagnostic pass, serialising workers
915936
on the load path early in the run. Measured on a large Laravel
916937
project (release build, 32 threads): eager population covers only
917-
~1,300 user classes, and the diagnostic pass then resolves ~17,400
918-
vendor classes lazily (~19 s of thread-CPU, roughly 30% of the
919-
run's total CPU) with
920-
6,425 cycle-break re-merges that eager dependency-first ordering
921-
would have avoided (eager population itself hit only 3).
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).
922942

923943
Likely next steps, in rough value order: reduce clone traffic in the
924944
hot type-engine paths (share via `Arc`/`Cow` instead of cloning
925-
`PhpType`/member vectors), include depended-upon vendor classes in
926-
eager population, and only then consider allocator-level fixes. The
927-
LSP workspace diagnostics pass uses the same collectors and has the
928-
same ceiling. Re-measure with `perf` (frame-pointer build) or the
929-
CPU-sampling loop in the Appendix after any change.
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.
930951

931952
---
932953

@@ -952,6 +973,38 @@ headless mode rather than special-casing the `analyze` call site.
952973

953974
---
954975

976+
## P43. `init_single_project` is the longest single-threaded stretch of a run
977+
978+
**Impact: Medium-High · Effort: Medium**
979+
980+
With the diagnostic pass now reaching ~22 of 32 cores (P35), project
981+
init is the least parallel phase left and the largest remaining share
982+
of wall clock. Per-phase timing of `analyze` on a large Laravel
983+
project (32-core machine, release build, warm page cache):
984+
985+
| Phase | Wall | Avg cores |
986+
| --- | --- | --- |
987+
| `init_single_project` | 1.09 s | 2.3 |
988+
| `discover_user_files` | 0.03 s | 0.8 |
989+
| Phase 1 index | 0.10 s | 7.1 |
990+
| Phase 1.5 eager populate | 0.22 s | 1.0 |
991+
| Phase 2 diagnostics | 1.31 s | 22.6 |
992+
993+
Init is ~40% of the run at barely two cores. It covers composer
994+
reading, autoload/classmap scanning, stub setup, and the vendor
995+
package scan, and it gates everything after it, so the same stretch is
996+
in front of the LSP's time-to-usable as well as the CLI's. Worth a
997+
per-phase breakdown inside init before choosing a fix — P32 (vendor
998+
scan reads every file twice) and P34 (eager population is
999+
single-threaded, the 1.0-core row above) are both already-filed pieces
1000+
of the same window.
1001+
1002+
Reproduce with the CPU-sampling loop in the Appendix, or by reading
1003+
`utime + stime` from `/proc/self/stat` at each phase boundary and
1004+
dividing by the phase's wall time.
1005+
1006+
---
1007+
9551008
# Remaining anti-pattern fixes
9561009

9571010
Most remaining depth-cap issues were addressed by eager class

src/completion/laravel_string_keys.rs

Lines changed: 98 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -394,52 +394,68 @@ impl Backend {
394394
keys
395395
}
396396

397-
pub(crate) fn cached_route_names(&self) -> Vec<String> {
398-
{
399-
let cache = self.laravel_string_key_cache.read();
400-
if let Some(ref names) = cache.route_names {
401-
return names.clone();
402-
}
397+
/// Read one slot of [`LaravelStringKeyCache`], building it under
398+
/// `build_lock` when empty.
399+
///
400+
/// The build is guarded rather than raced because every enumeration
401+
/// walks the workspace from disk: the parallel diagnostic pass
402+
/// otherwise has all N workers miss the same empty slot at once and
403+
/// each repeat the identical walk. Waiters re-check the slot after
404+
/// acquiring the guard, so exactly one walk happens per
405+
/// invalidation.
406+
pub(crate) fn cached_laravel_enumeration<T: Clone>(
407+
&self,
408+
build_lock: &parking_lot::Mutex<()>,
409+
read: impl Fn(&crate::LaravelStringKeyCache) -> Option<T>,
410+
store: impl Fn(&mut crate::LaravelStringKeyCache, T),
411+
build: impl FnOnce() -> T,
412+
) -> T {
413+
if let Some(cached) = read(&self.laravel_string_key_cache.read()) {
414+
return cached;
403415
}
404-
let names = crate::virtual_members::laravel::enumerate_all_route_names(self);
405-
self.laravel_string_key_cache.write().route_names = Some(names.clone());
406-
names
416+
let _build_guard = build_lock.lock();
417+
if let Some(cached) = read(&self.laravel_string_key_cache.read()) {
418+
return cached;
419+
}
420+
let value = build();
421+
store(&mut self.laravel_string_key_cache.write(), value.clone());
422+
value
423+
}
424+
425+
pub(crate) fn cached_route_names(&self) -> Vec<String> {
426+
self.cached_laravel_enumeration(
427+
&self.laravel_string_key_build_locks.route_names,
428+
|cache| cache.route_names.clone(),
429+
|cache, names| cache.route_names = Some(names),
430+
|| crate::virtual_members::laravel::enumerate_all_route_names(self),
431+
)
407432
}
408433

409434
pub(crate) fn cached_config_keys(&self) -> Vec<String> {
410-
{
411-
let cache = self.laravel_string_key_cache.read();
412-
if let Some(ref keys) = cache.config_keys {
413-
return keys.clone();
414-
}
415-
}
416-
let keys = self.enumerate_all_config_keys();
417-
self.laravel_string_key_cache.write().config_keys = Some(keys.clone());
418-
keys
435+
self.cached_laravel_enumeration(
436+
&self.laravel_string_key_build_locks.config_keys,
437+
|cache| cache.config_keys.clone(),
438+
|cache, keys| cache.config_keys = Some(keys),
439+
|| self.enumerate_all_config_keys(),
440+
)
419441
}
420442

421443
pub(crate) fn cached_view_names(&self) -> Vec<String> {
422-
{
423-
let cache = self.laravel_string_key_cache.read();
424-
if let Some(ref names) = cache.view_names {
425-
return names.clone();
426-
}
427-
}
428-
let names = self.enumerate_all_view_names();
429-
self.laravel_string_key_cache.write().view_names = Some(names.clone());
430-
names
444+
self.cached_laravel_enumeration(
445+
&self.laravel_string_key_build_locks.view_names,
446+
|cache| cache.view_names.clone(),
447+
|cache, names| cache.view_names = Some(names),
448+
|| self.enumerate_all_view_names(),
449+
)
431450
}
432451

433452
pub(crate) fn cached_trans_keys(&self) -> Vec<String> {
434-
{
435-
let cache = self.laravel_string_key_cache.read();
436-
if let Some(ref keys) = cache.trans_keys {
437-
return keys.clone();
438-
}
439-
}
440-
let keys = self.enumerate_all_trans_keys();
441-
self.laravel_string_key_cache.write().trans_keys = Some(keys.clone());
442-
keys
453+
self.cached_laravel_enumeration(
454+
&self.laravel_string_key_build_locks.trans_keys,
455+
|cache| cache.trans_keys.clone(),
456+
|cache, keys| cache.trans_keys = Some(keys),
457+
|| self.enumerate_all_trans_keys(),
458+
)
443459
}
444460
}
445461

@@ -995,4 +1011,50 @@ final class UserPermissionController extends BaseController\n\
9951011
);
9961012
}
9971013
}
1014+
1015+
/// Concurrent first callers must share one build, not run one each:
1016+
/// every enumeration behind these accessors walks the workspace from
1017+
/// disk, and the diagnostic pass hits them from all N workers at once.
1018+
#[test]
1019+
fn concurrent_first_callers_build_the_enumeration_once() {
1020+
use std::sync::atomic::{AtomicUsize, Ordering};
1021+
1022+
let backend = crate::Backend::new_test();
1023+
let builds = AtomicUsize::new(0);
1024+
let build_lock = parking_lot::Mutex::new(());
1025+
1026+
let results: Vec<Vec<String>> = std::thread::scope(|scope| {
1027+
let handles: Vec<_> = (0..16)
1028+
.map(|_| {
1029+
let backend = &backend;
1030+
let builds = &builds;
1031+
let build_lock = &build_lock;
1032+
scope.spawn(move || {
1033+
backend.cached_laravel_enumeration(
1034+
build_lock,
1035+
|cache| cache.route_names.clone(),
1036+
|cache, names| cache.route_names = Some(names),
1037+
|| {
1038+
builds.fetch_add(1, Ordering::SeqCst);
1039+
// Long enough that an unguarded
1040+
// check-then-fill has every thread miss.
1041+
std::thread::sleep(std::time::Duration::from_millis(50));
1042+
vec!["home".to_string()]
1043+
},
1044+
)
1045+
})
1046+
})
1047+
.collect();
1048+
handles.into_iter().map(|h| h.join().unwrap()).collect()
1049+
});
1050+
1051+
assert_eq!(
1052+
builds.load(Ordering::SeqCst),
1053+
1,
1054+
"the enumeration must be built once and shared, not once per caller"
1055+
);
1056+
for names in &results {
1057+
assert_eq!(names, &vec!["home".to_string()]);
1058+
}
1059+
}
9981060
}

src/lib.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,28 @@ pub(crate) struct LaravelStringKeyCache {
332332
>,
333333
}
334334

335+
/// Compute-once guards for the entries of [`LaravelStringKeyCache`].
336+
///
337+
/// Every enumeration behind that cache walks the workspace from disk.
338+
/// A plain check-then-fill cache stampedes under the parallel
339+
/// diagnostic pass: all workers miss the empty slot in the same
340+
/// instant, and each one repeats the identical walk while the others
341+
/// queue behind the workspace index lock. Holding the matching guard
342+
/// across the build means one worker walks and the rest wait once,
343+
/// then read the filled slot.
344+
///
345+
/// One guard per slot rather than one shared guard: the enumerations
346+
/// are independent, and a shared guard would let a worker that needs
347+
/// view names wait out an unrelated route-name build.
348+
#[derive(Default)]
349+
pub(crate) struct LaravelStringKeyBuildLocks {
350+
pub route_names: parking_lot::Mutex<()>,
351+
pub config_keys: parking_lot::Mutex<()>,
352+
pub view_names: parking_lot::Mutex<()>,
353+
pub trans_keys: parking_lot::Mutex<()>,
354+
pub config_trees: parking_lot::Mutex<()>,
355+
}
356+
335357
impl LaravelStringKeyCache {
336358
fn invalidate_for_uri(&mut self, uri: &str) {
337359
if uri.contains("/routes/") {
@@ -596,6 +618,9 @@ pub struct Backend {
596618
/// or `lang/` is updated.
597619
pub(crate) laravel_provider_resources: Arc<RwLock<virtual_members::laravel::ProviderResources>>,
598620
pub(crate) laravel_string_key_cache: Arc<RwLock<LaravelStringKeyCache>>,
621+
/// Compute-once guards for `laravel_string_key_cache`; see
622+
/// [`LaravelStringKeyBuildLocks`].
623+
pub(crate) laravel_string_key_build_locks: Arc<LaravelStringKeyBuildLocks>,
599624
pub(crate) schema_index: Arc<RwLock<virtual_members::laravel::database_schema::SchemaIndex>>,
600625
/// Per-target member completion cache.
601626
///
@@ -864,6 +889,7 @@ impl Backend {
864889
virtual_members::laravel::ProviderResources::default(),
865890
)),
866891
laravel_string_key_cache: Arc::new(RwLock::new(LaravelStringKeyCache::default())),
892+
laravel_string_key_build_locks: Arc::new(LaravelStringKeyBuildLocks::default()),
867893
schema_index: Arc::new(RwLock::new(
868894
virtual_members::laravel::database_schema::SchemaIndex::default(),
869895
)),
@@ -948,6 +974,7 @@ impl Backend {
948974
virtual_members::laravel::ProviderResources::default(),
949975
)),
950976
laravel_string_key_cache: Arc::new(RwLock::new(LaravelStringKeyCache::default())),
977+
laravel_string_key_build_locks: Arc::new(LaravelStringKeyBuildLocks::default()),
951978
schema_index: Arc::new(RwLock::new(
952979
virtual_members::laravel::database_schema::SchemaIndex::default(),
953980
)),
@@ -1556,6 +1583,7 @@ impl Backend {
15561583
laravel_date_seed_uris: Arc::clone(&self.laravel_date_seed_uris),
15571584
laravel_provider_resources: Arc::clone(&self.laravel_provider_resources),
15581585
laravel_string_key_cache: Arc::clone(&self.laravel_string_key_cache),
1586+
laravel_string_key_build_locks: Arc::clone(&self.laravel_string_key_build_locks),
15591587
schema_index: Arc::clone(&self.schema_index),
15601588
member_completion_cache: Arc::clone(&self.member_completion_cache),
15611589
stub_function_index: Arc::clone(&self.stub_function_index),

src/virtual_members/laravel/config_values.rs

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -485,15 +485,12 @@ impl Backend {
485485
}
486486

487487
pub(crate) fn cached_config_trees(&self) -> Vec<(String, ConfigNode)> {
488-
{
489-
let cache = self.laravel_string_key_cache.read();
490-
if let Some(ref trees) = cache.config_trees {
491-
return trees.clone();
492-
}
493-
}
494-
let trees = self.enumerate_config_trees();
495-
self.laravel_string_key_cache.write().config_trees = Some(trees.clone());
496-
trees
488+
self.cached_laravel_enumeration(
489+
&self.laravel_string_key_build_locks.config_trees,
490+
|cache| cache.config_trees.clone(),
491+
|cache, trees| cache.config_trees = Some(trees),
492+
|| self.enumerate_config_trees(),
493+
)
497494
}
498495

499496
fn enumerate_config_trees(&self) -> Vec<(String, ConfigNode)> {

0 commit comments

Comments
 (0)