Skip to content

Commit 445c65a

Browse files
committed
Finish resolved_class_cache generic-arg specialisation
1 parent d4eac18 commit 445c65a

4 files changed

Lines changed: 94 additions & 43 deletions

File tree

docs/todo.md

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,9 @@ within the same impact tier.
2525

2626
| # | Item | Impact | Effort |
2727
| --- | --------------------------------------------------------------------------------------------------------------------- | ---------- | ------ |
28-
| | Clear [refactoring gate](todo/refactor.md) | | |
28+
| ER5 | [Mago-style separated metadata](todo/eager-resolution.md#er5-mago-style-separated-metadata) | High | High |
2929
| P31 | [Reference index stores per-span entries when consumers only read distinct URIs](todo/performance.md#p31-reference-index-stores-per-span-entries-when-consumers-only-read-distinct-uris) | Medium | Low-Medium |
3030
| P33 | [Workspace diagnostics leaves the whole project fully resolved in memory](todo/performance.md#p33-workspace-diagnostics-leaves-the-whole-project-fully-resolved-in-memory) | High | Medium-High |
31-
| P9 | [`resolved_class_cache` generic-arg specialisation](todo/performance.md#p9-resolved_class_cache-generic-arg-specialisation) | Medium | Medium |
3231
| X10 | [Interactive requests block on the workspace index lock during initial indexing](todo/indexing.md#x10-interactive-requests-block-on-the-workspace-index-lock-during-initial-indexing) | Medium | Medium |
3332
| L21 | [Tighten the supertype-where-subtype comparison escape hatch (blocked on resolver precision)](todo/laravel.md#l21-tighten-the-supertype-where-subtype-comparison-escape-hatch-blocked-on-resolver-precision) | Medium | High |
3433
| L18 | [`Macroable::mixin()` registrations](todo/laravel.md) | Low | Medium |
@@ -197,14 +196,12 @@ unlikely to move the needle for most users.
197196
| E6 | Stub install prompt for non-Composer projects | Low | Low |
198197
| E7 | [Stub-based framework patches](todo/external-stubs.md#e7-stub-based-framework-patches) | Medium | Medium |
199198
| | **[Performance](todo/performance.md) / [Eager Resolution](todo/eager-resolution.md)** | | |
200-
| ER5 | [Mago-style separated metadata](todo/eager-resolution.md#er5-mago-style-separated-metadata) | High | High |
201199
| P29 | [Migrate to `mago-phpdoc-syntax`](todo/performance.md#p29-migrate-to-mago-phpdoc-syntax) (drop deprecated `mago-docblock` / `mago-type-syntax`) | Medium | Medium |
202200
| 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 |
203201
| P16 | [Pre-parsed stub format (eliminate raw PHP embedding)](todo/performance.md#p16-pre-parsed-stub-format-eliminate-raw-php-embedding) | High | Medium-High |
204202
| 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 |
205203
| 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 |
206204
| P14 | [Eager docblock parsing into structured fields](todo/performance.md#p14-eager-docblock-parsing-into-structured-fields) | Medium | Medium |
207-
| P9 | [`resolved_class_cache` generic-arg specialisation](todo/performance.md#p9-resolved_class_cache-generic-arg-specialisation) | Medium | Medium |
208205
| P27 | [`object`/`?object` call-return check re-resolves the subject a second time](todo/performance.md#p27-objectobject-call-return-check-re-resolves-the-subject-a-second-time) | Medium | Low |
209206
| P11 | [Uncached base-resolution in `build_scope_methods_for_builder`](todo/performance.md#p11-uncached-base-resolution-in-build_scope_methods_for_builder) | Low-Medium | Low |
210207
| P3 | Parallel pre-filter in `find_implementors` | Low-Medium | Medium |

docs/todo/performance.md

Lines changed: 0 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -51,36 +51,6 @@ threshold (e.g. 8 files).
5151

5252
---
5353

54-
## P9. `resolved_class_cache` generic-arg specialisation
55-
56-
**Impact: Medium · Effort: Medium**
57-
58-
The resolved-class cache is keyed by `(FQN, Vec<String>)`. Every
59-
distinct generic instantiation of the same class (e.g.
60-
`Builder<User>`, `Builder<Order>`, `Builder<Product>`) triggers a
61-
full `resolve_class_fully` call, even though the base resolution
62-
(inheritance merging, trait merging, virtual member injection) is
63-
identical. Only the final generic substitution differs.
64-
65-
In a Laravel codebase with hundreds of Eloquent models, this means
66-
`Builder` is fully resolved hundreds of times, once per model.
67-
68-
### Fix
69-
70-
Cache the base-resolved class (before generic substitution)
71-
separately, keyed by FQN alone. When a generic instantiation is
72-
requested, look up the base-resolved class and apply
73-
`apply_substitution` on top. The substitution step is cheap
74-
(tree walk) compared to the full resolution (inheritance walking,
75-
trait merging, virtual member providers).
76-
77-
This requires splitting `resolve_class_fully` into two stages:
78-
base resolution (cached by FQN) and generic specialisation (cached
79-
by `(FQN, Vec<String>)` as today, but with a much cheaper miss
80-
path).
81-
82-
---
83-
8454
## P11. Uncached base-resolution in `build_scope_methods_for_builder`
8555

8656
**Impact: Low-Medium · Effort: Low**

src/virtual_members/resolve.rs

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ pub fn populate_from_sorted(
115115
// for related classes, which hit the cache (already populated)
116116
// 4. Merge interfaces — also hit cache for resolved interfaces
117117
// 5. Store result in cache
118-
resolve_class_fully_inner(&raw_class, class_loader, Some(cache), &[]);
118+
resolve_class_fully_inner(&raw_class, class_loader, Some(cache));
119119
}
120120
}
121121

@@ -146,7 +146,7 @@ pub fn resolve_class_fully(
146146
class: &ClassInfo,
147147
class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
148148
) -> Arc<ClassInfo> {
149-
resolve_class_fully_inner(class, class_loader, None, &[])
149+
resolve_class_fully_inner(class, class_loader, None)
150150
}
151151

152152
/// Cached variant of [`resolve_class_fully`].
@@ -166,7 +166,7 @@ pub fn resolve_class_fully_cached(
166166
class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
167167
cache: &ResolvedClassCache,
168168
) -> Arc<ClassInfo> {
169-
resolve_class_fully_inner(class, class_loader, Some(cache), &[])
169+
resolve_class_fully_inner(class, class_loader, Some(cache))
170170
}
171171

172172
/// Resolve a class fully, using the cache when available.
@@ -182,7 +182,7 @@ pub fn resolve_class_fully_maybe_cached(
182182
cache: Option<&ResolvedClassCache>,
183183
) -> Arc<ClassInfo> {
184184
let started = std::time::Instant::now();
185-
let result = resolve_class_fully_inner(class, class_loader, cache, &[]);
185+
let result = resolve_class_fully_inner(class, class_loader, cache);
186186
let elapsed = started.elapsed();
187187
if elapsed >= std::time::Duration::from_millis(50) {
188188
tracing::info!(
@@ -220,7 +220,7 @@ pub fn resolve_class_fully_with_generics(
220220
) -> Arc<ClassInfo> {
221221
// Fast path: no generics — just do the base resolution.
222222
if generic_args.is_empty() {
223-
return resolve_class_fully_inner(class, class_loader, cache, &[]);
223+
return resolve_class_fully_inner(class, class_loader, cache);
224224
}
225225

226226
// Check the cache for (FQN, generic_args).
@@ -236,7 +236,7 @@ pub fn resolve_class_fully_with_generics(
236236

237237
let started = std::time::Instant::now();
238238
// Resolve the base class (cached at (FQN, [])).
239-
let base = resolve_class_fully_inner(class, class_loader, cache, &[]);
239+
let base = resolve_class_fully_inner(class, class_loader, cache);
240240

241241
// Apply generic substitution.
242242
let result = if !base.template_params.is_empty() {
@@ -278,7 +278,7 @@ pub fn resolve_class_fully_with_type_args(
278278
generic_args: &[crate::php_type::PhpType],
279279
) -> Arc<ClassInfo> {
280280
if generic_args.is_empty() {
281-
return resolve_class_fully_inner(class, class_loader, cache, &[]);
281+
return resolve_class_fully_inner(class, class_loader, cache);
282282
}
283283

284284
let generic_arg_strings: Vec<String> = generic_args.iter().map(|arg| arg.to_string()).collect();
@@ -293,14 +293,23 @@ pub fn resolve_class_fully_with_type_args(
293293

294294
/// Shared implementation behind [`resolve_class_fully`] and
295295
/// [`resolve_class_fully_cached`].
296+
///
297+
/// This always produces the *base* resolution, cached under the bare
298+
/// FQN key `(FQN, [])`. Generic specialisation is a separate, much
299+
/// cheaper stage layered on top by
300+
/// [`resolve_class_fully_with_generics`]: it looks up this base result
301+
/// and applies [`apply_generic_args`](crate::inheritance::apply_generic_args),
302+
/// caching the substituted class under `(FQN, generic_args)`. Keeping the
303+
/// two stages distinct means the expensive inheritance/trait/virtual-member
304+
/// merge runs once per class, no matter how many generic instantiations
305+
/// (`Builder<User>`, `Builder<Order>`, …) are requested.
296306
fn resolve_class_fully_inner(
297307
class: &ClassInfo,
298308
class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
299309
cache: Option<&ResolvedClassCache>,
300-
generic_args: &[String],
301310
) -> Arc<ClassInfo> {
302311
let fqn = class.fqn();
303-
let cache_key: ResolvedClassCacheKey = (fqn, generic_args.to_vec());
312+
let cache_key: ResolvedClassCacheKey = (fqn, Vec::new());
304313

305314
// ── Reload raw class from class_loader ──────────────────────────
306315
// Callers sometimes pass a ClassInfo that has already been through

src/virtual_members/tests.rs

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1065,6 +1065,81 @@ fn resolve_class_fully_with_type_args_caches_specialisation() {
10651065
);
10661066
}
10671067

1068+
#[test]
1069+
fn base_resolution_shared_across_distinct_specialisations() {
1070+
// The point of the two-stage split: the expensive base resolution
1071+
// (inheritance/trait/virtual-member merge) runs once per class, no
1072+
// matter how many distinct generic instantiations are requested. A
1073+
// parent that the inheritance walk must load lets us count whether
1074+
// the base merge re-ran: on a cached base hit the parent is not
1075+
// loaded again.
1076+
let mut base = make_class("Base");
1077+
base.methods
1078+
.push(Arc::new(make_method("save", Some("bool"))));
1079+
1080+
let mut collection = make_class("Collection");
1081+
collection.template_params.push(atom("TValue"));
1082+
collection.parent_class = Some(atom("Base"));
1083+
collection
1084+
.methods
1085+
.push(Arc::new(make_method("first", Some("TValue"))));
1086+
1087+
let base_arc = Arc::new(base);
1088+
let collection_arc = Arc::new(collection);
1089+
let base_loads = std::cell::Cell::new(0u32);
1090+
let class_loader = |name: &str| -> Option<Arc<crate::types::ClassInfo>> {
1091+
match name {
1092+
"Base" => {
1093+
base_loads.set(base_loads.get() + 1);
1094+
Some(Arc::clone(&base_arc))
1095+
}
1096+
"Collection" => Some(Arc::clone(&collection_arc)),
1097+
_ => None,
1098+
}
1099+
};
1100+
1101+
let cache = new_resolved_class_cache();
1102+
1103+
// First specialisation triggers the one-and-only base resolution.
1104+
let user = resolve_class_fully_with_type_args(
1105+
&collection_arc,
1106+
&class_loader,
1107+
Some(&cache),
1108+
&[PhpType::parse("User")],
1109+
);
1110+
let after_first = base_loads.get();
1111+
assert!(after_first > 0, "base resolution must load the parent");
1112+
1113+
// A different specialisation must reuse the cached base, loading the
1114+
// parent zero additional times.
1115+
let order = resolve_class_fully_with_type_args(
1116+
&collection_arc,
1117+
&class_loader,
1118+
Some(&cache),
1119+
&[PhpType::parse("Order")],
1120+
);
1121+
assert_eq!(
1122+
base_loads.get(),
1123+
after_first,
1124+
"distinct generic specialisation must not re-run base resolution"
1125+
);
1126+
1127+
// Each specialisation still substitutes its own type argument.
1128+
assert_eq!(
1129+
user.get_method("first").and_then(|m| m.return_type_str()),
1130+
Some("User".to_string())
1131+
);
1132+
assert_eq!(
1133+
order.get_method("first").and_then(|m| m.return_type_str()),
1134+
Some("Order".to_string())
1135+
);
1136+
1137+
let map = cache.read();
1138+
assert!(map.contains_key(&(atom("Collection"), Vec::new())));
1139+
assert!(map.contains_key(&(atom("Collection"), vec!["User".to_string()])));
1140+
assert!(map.contains_key(&(atom("Collection"), vec!["Order".to_string()])));
1141+
}
1142+
10681143
#[test]
10691144
fn evict_removes_direct_match() {
10701145
let mut cache = make_cache();

0 commit comments

Comments
 (0)