Skip to content

Commit 4f15fec

Browse files
committed
Lower memory use across every stored type
1 parent b10603e commit 4f15fec

64 files changed

Lines changed: 1233 additions & 1328 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3535

3636
### Changed
3737

38+
- **Lower memory use across every stored type.** The internal representation of a PHP type is now 24 bytes instead of 64, so every parameter, return, and property type (and every type nested inside them) takes less than half the space it used to. On large Laravel projects this cuts peak memory by roughly 15% and the live heap by roughly 19%, with no change to what PHPantom resolves.
3839
- **Lower memory use when resolving class hierarchies.** Resolving a class no longer copies every inherited or synthesized member into it. Members that a merge does not actually rewrite are shared with their source across the whole workspace, and the copies a merge does produce (template substitution, trait and interface merging, Eloquent Builder and scope forwarding) are deduplicated so that value-identical results share a single allocation instead of one per class. Method parameter lists are shared the same way. On large Laravel projects, where nearly every Eloquent model resolves through a generic base and forwards the same query-builder methods, this roughly halves the memory held by the resolved-class cache during whole-project analysis and speeds up the merge itself.
3940
- **Property and constant sharing extends the same memory win.** Inherited and synthesized properties and constants are now shared and interned the same way methods already were, instead of being cloned onto every class that inherits them. This further reduces the resolved-class cache's memory footprint on large projects, on top of the method sharing above.
4041
- **Classes are pre-resolved for the whole workspace after startup.** Once the background index completes, every known class is resolved in dependency order even when workspace diagnostics are disabled, so the first completion, hover, or go-to-definition against any class reads a warm cache instead of resolving on demand. Edits still re-resolve only the affected classes.

docs/todo.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,10 @@ within the same impact tier.
2727
| --- | --------------------------------------------------------------------------------------------------------------------- | ---------- | ------ |
2828
| P36 | [Diagnostic-path call sites re-merge inheritance per call](todo/performance.md#p36-diagnostic-path-call-sites-re-merge-inheritance-per-call-instead-of-reading-the-resolved-class-cache) | Medium | Low |
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 |
30-
| P37 | [`PhpType` is 64 bytes and is embedded ~3 M times](todo/performance.md#p37-phptype-is-64-bytes-and-is-embedded-3-m-times) | High | Medium |
31-
| P38 | [Resolved type values are duplicated ~33x](todo/performance.md#p38-resolved-type-values-are-duplicated-33x) | High | High |
30+
| P38 | [Resolved type values are duplicated ~34x](todo/performance.md#p38-resolved-type-values-are-duplicated-34x) | High | High |
3231
| P39 | [`SymbolKind` stores owned strings per span](todo/performance.md#p39-symbolkind-stores-owned-strings-per-span) | Medium | Low-Medium |
3332
| P40 | [`method_index` is a per-class `HashMap` even when the member vec is shared](todo/performance.md#p40-method_index-is-a-per-class-hashmap-even-when-the-member-vec-is-shared) | Low-Medium | Low |
34-
| P41 | [3.8 M live allocations cost ~130 MB in allocator overhead](todo/performance.md#p41-38-m-live-allocations-cost-130-mb-in-allocator-overhead) | Medium | Medium |
33+
| P41 | [3.8 M live allocations cost ~170 MB in allocator overhead](todo/performance.md#p41-38-m-live-allocations-cost-170-mb-in-allocator-overhead) | Medium | Medium |
3534
| 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 |
3635
| 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 |
3736
| 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 |

docs/todo/performance.md

Lines changed: 45 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1095,7 +1095,7 @@ bounded by the same fix as P33.
10951095

10961096
---
10971097

1098-
## Memory baseline (2026-07-26)
1098+
## Memory baseline (2026-07-26, refreshed after the `PhpType` shrink)
10991099

11001100
Measured with the `mem-audit` cargo feature, which installs a counting
11011101
global allocator and prints a deep-size walk of every long-lived store
@@ -1125,15 +1125,18 @@ mimalloc, since that is what every shipped Linux binary runs (see
11251125

11261126
| Measure | Mid-size Laravel app | Large Laravel app |
11271127
| ----------------------------- | -------------------- | ----------------- |
1128-
| Peak RSS (`VmHWM`) | 578 MB | 834 MB |
1129-
| RSS after mimalloc collect | 552 MB | 814 MB |
1130-
| Live heap (allocator-reported)| 415 MB | 634 MB |
1131-
| Live allocations | 2.46 M | 3.83 M |
1132-
| Allocator + page overhead | 136 MB (25% of RSS) | 179 MB (22%) |
1133-
1134-
For reference, the same run under plain glibc `malloc` (no mimalloc)
1135-
peaks higher despite a *lower* overhead percentage: 622 MB / 855 MB
1136-
peak, 100 MB (19%) / 133 MB (17%) overhead. mimalloc holds more
1128+
| Peak RSS (`VmHWM`) | 505 MB | 706 MB |
1129+
| RSS after mimalloc collect | 477 MB | 686 MB |
1130+
| Live heap (allocator-reported)| 344 MB | 514 MB |
1131+
| Live allocations | 2.45 M | 3.81 M |
1132+
| Allocator + page overhead | 133 MB (28% of RSS) | 172 MB (25%) |
1133+
1134+
The glibc figures below were taken against the original baseline (the
1135+
one with a 64-byte `PhpType`) and have not been re-measured since; the
1136+
relative picture they illustrate is unchanged. Under plain glibc
1137+
`malloc` (no mimalloc) the same run peaked higher despite a *lower*
1138+
overhead percentage: 622 MB / 855 MB peak, 100 MB (19%) / 133 MB (17%)
1139+
overhead. mimalloc holds more
11371140
allocator/page overhead in percentage terms but a smaller absolute
11381141
peak, because PHPantom already tunes it (`configure_allocator`: a
11391142
short purge delay plus a per-process THP opt-out) to return freed
@@ -1143,18 +1146,18 @@ Bytes released per store (drop-and-measure, mid-size / large):
11431146

11441147
| Store | Released | Allocations |
11451148
| ---------------------- | ---------------- | --------------- |
1146-
| `resolved_class_cache` | 205 MB / 336 MB | 1.17 M / 1.96 M |
1149+
| `resolved_class_cache` | 143 MB / 237 MB | 1.17 M / 1.94 M |
11471150
| `symbol_maps` | 40 MB / 64 MB | 367 K / 602 K |
11481151
| `reference_index` | 26 MB / 43 MB | 204 K / 330 K |
1149-
| `uri_classes_index` | 25 MB / 36 MB | 122 K / 172 K |
1152+
| `uri_classes_index` | 25 MB / 27 MB | 122 K / 171 K |
11501153
| `resolved_names` | 4.6 MB / 6.7 MB | 53 K / 75 K |
11511154
| everything else | < 6 MB each ||
11521155

11531156
Within the member data, the largest field-level groups on the large app
1154-
are `MethodInfo` structs (122 MB across 223 K distinct methods at 560 B
1155-
each), parameter vectors (86 MB across 187 K vectors holding 312 K
1156-
parameters at 256 B each), `PhpType` heap payloads (~66 MB), and
1157-
`method_index` (20 MB).
1157+
are `MethodInfo` structs (92 MB across 232 K distinct methods at 400 B
1158+
each), parameter vectors (51 MB across 191 K vectors holding 325 K
1159+
parameters at 136 B each), `PhpType` heap payloads (~33 MB), and
1160+
`method_index` (21 MB).
11581161

11591162
Two findings from the baseline are worth recording because they close
11601163
off obvious-looking ideas:
@@ -1172,72 +1175,30 @@ off obvious-looking ideas:
11721175

11731176
---
11741177

1175-
## P37. `PhpType` is 64 bytes and is embedded ~3 M times
1176-
1177-
**Impact: High · Effort: Medium**
1178-
1179-
`PhpType` is 64 bytes because its largest variants force that size:
1180-
`Conditional` carries a `String` plus three `Box`es, `Callable` carries
1181-
a `String` plus a `Vec` plus an `Option<Box>`, and `Generic` carries a
1182-
`String` plus a `Vec`. Every `PhpType` slot in the program pays for
1183-
them.
1184-
1185-
Measured on the large Laravel app: **2.94 M slots** (1.90 M sitting
1186-
inline in struct fields, 1.05 M inside heap allocations). The variants
1187-
that force the 64-byte size are rare — the mix is `Named` 70.6%,
1188-
`Generic` 12.8%, `Union` 10.2%, `Callable` 2.9%, `Nullable` 1.2%,
1189-
`Conditional` 1.1%, everything else under 0.5%.
1190-
1191-
The cost lands squarely on the hottest structs. `MethodInfo` is 560 B,
1192-
of which four `Option<PhpType>` fields (`return_type`,
1193-
`native_return_type`, `conditional_return`, `if_this_is`) account for
1194-
256 B. `ParameterInfo` is 256 B, of which three `Option<PhpType>`
1195-
fields account for 192 B.
1196-
1197-
### Fix
1198-
1199-
Box the rare variants so the enum's payload fits a pointer, e.g.
1200-
`Conditional(Box<ConditionalType>)`, `Callable(Box<CallableType>)`,
1201-
`Generic(Box<GenericType>)`, `Union(Box<Vec<PhpType>>)`,
1202-
`IntRange(Box<(String, String)>)`. `Named(Atom)` stays inline, which is
1203-
the 70% case, so the common path gains an indirection nowhere.
1204-
1205-
Reclaim at 16 B per slot: **83 MB** on the mid-size app, **135 MB** on
1206-
the large one. `MethodInfo` drops to ~368 B and `ParameterInfo` to
1207-
~112 B. A less aggressive version that only boxes `Conditional` and
1208-
`Callable` (leaving 24 B, since `Box<[T]>` and `Box<str>` are fat
1209-
pointers) still reclaims roughly two thirds of that.
1210-
1211-
Note `Generic`'s first field is a `String` holding a class name or
1212-
keyword — a bounded set that should be an `Atom` regardless.
1213-
1214-
---
1215-
1216-
## P38. Resolved type values are duplicated ~33x
1178+
## P38. Resolved type values are duplicated ~34x
12171179

12181180
**Impact: High · Effort: High**
12191181

1220-
The same measurement pass that produced P37 rendered every live
1221-
`PhpType` and counted distinct forms: **1.68 M values collapse to
1222-
50,590 distinct rendered types** on the large Laravel app, and 1.02 M to
1223-
30,903 on the mid-size one. Both are almost exactly **33x duplication**.
1224-
Every `string`, `?Carbon`, `Collection<User>`, and
1225-
`Builder<TModel>` is re-allocated per method, per parameter, per class
1226-
that inherits it.
1182+
The memory audit renders every live `PhpType` and counts distinct
1183+
forms: **1.76 M values collapse to 51,272 distinct rendered types** on
1184+
the large Laravel app, and 1.07 M to 31,074 on the mid-size one. Both
1185+
are almost exactly **34x duplication**. Every `string`, `?Carbon`,
1186+
`Collection<User>`, and `Builder<TModel>` is re-allocated per method,
1187+
per parameter, per class that inherits it.
12271188

12281189
Interning types the way `Atom` interns names would collapse both halves
1229-
of the cost at once: each slot becomes a pointer-or-index handle, and
1230-
each distinct type is allocated once. Combined with the slot shrink from
1231-
P37 the ceiling is roughly 230 MB on the large app and 140 MB on the
1232-
mid-size one — the single largest structural win available.
1233-
1234-
This is deliberately filed separately from P37 because it is a much
1235-
larger change: `PhpType` is currently a value that substitution paths
1236-
mutate in place, so interning means moving to a construct-and-intern
1237-
API (`PhpType::named(...)`, `PhpType::union(...)`) and auditing every
1238-
site that builds or rewrites a type. Do P37 first — it is
1239-
self-contained, it delivers most of the per-slot win, and it does not
1240-
foreclose interning later.
1190+
of the remaining cost at once: each slot becomes a pointer-or-index
1191+
handle (24 B down to 8 B, worth another ~24 MB of slot bytes on the
1192+
large app on its own), and each distinct type is allocated once instead
1193+
of ~34 times. The audit's "PhpType 24→16 B" projection covers only the
1194+
slot half; the duplicate payloads are the larger share.
1195+
1196+
This is a much larger change than the slot shrink that preceded it:
1197+
`PhpType` is a value that substitution paths mutate in place, so
1198+
interning means moving fully to a construct-and-intern API (the
1199+
`PhpType::union(...)` / `PhpType::generic(...)` constructors added by
1200+
the slot shrink are the beginning of that surface) and auditing every
1201+
site that builds or rewrites a type.
12411202

12421203
Take care not to feed the interner unbounded text: `Raw` holds
12431204
free-text parse fallbacks and `Literal` holds source literals, so those
@@ -1304,17 +1265,17 @@ Two options, either of which is small:
13041265

13051266
---
13061267

1307-
## P41. 3.8 M live allocations cost ~180 MB in allocator overhead
1268+
## P41. 3.8 M live allocations cost ~170 MB in allocator overhead
13081269

13091270
**Impact: Medium · Effort: Medium**
13101271

13111272
Independent of what we store, *how many* allocations we hold costs
13121273
real memory. On the large Laravel app, mimalloc (the allocator every
13131274
shipped Linux binary runs — see the "Memory baseline" note above)
1314-
reports 634 MB live across 3.83 M allocations (average 174 B), while
1315-
peak RSS is 834 MB. The 179 MB difference (22% of RSS; 25% on the
1275+
reports 514 MB live across 3.81 M allocations (average 142 B), while
1276+
peak RSS is 706 MB. The 172 MB difference (25% of RSS; 28% on the
13161277
mid-size app) is mimalloc's per-size-class page and slot rounding —
1317-
roughly 47 B per live allocation.
1278+
roughly 45 B per live allocation.
13181279

13191280
This is *after* the existing `configure_allocator` tuning (a short
13201281
purge delay plus a per-process transparent-huge-pages opt-out — see
@@ -1328,11 +1289,10 @@ absolute peak RSS. `MALLOC_ARENA_MAX` capping under plain glibc moves
13281289
peak RSS (622 MB to 516 MB on the mid-size app) but barely moves the
13291290
trimmed live figure, confirming arena count was never the lever.
13301291

1331-
What is left is allocation *count* itself: at ~47 B of overhead per
1292+
What is left is allocation *count* itself: at ~45 B of overhead per
13321293
live allocation, each allocation avoided is worth more than its own
1333-
`size_of`. P37, P39, and P40 each remove hundreds of thousands of
1334-
allocations as a side effect (folding `Option<PhpType>` heap payloads
1335-
into fewer, larger buffers; interning `SymbolKind` strings; sharing
1294+
`size_of`. P39 and P40 each remove hundreds of thousands of allocations
1295+
as a side effect (interning `SymbolKind` strings; sharing
13361296
`method_index`), so re-measure this line after those land. If a
13371297
meaningful gap remains afterward, the next lever is arena-allocating
13381298
the member metadata (bump-allocate `MethodInfo`/`PropertyInfo`/

src/class_lookup.rs

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -337,11 +337,11 @@ pub(crate) fn is_subtype_of_typed(
337337

338338
// ── Nullable normalisation ──────────────────────────────────
339339
if let PhpType::Nullable(inner) = subtype {
340-
let as_union = PhpType::Union(vec![inner.as_ref().clone(), PhpType::null()]);
340+
let as_union = PhpType::union(vec![inner.as_ref().clone(), PhpType::null()]);
341341
return is_subtype_of_typed(&as_union, supertype, class_loader);
342342
}
343343
if let PhpType::Nullable(inner) = supertype {
344-
let as_union = PhpType::Union(vec![inner.as_ref().clone(), PhpType::null()]);
344+
let as_union = PhpType::union(vec![inner.as_ref().clone(), PhpType::null()]);
345345
return is_subtype_of_typed(subtype, &as_union, class_loader);
346346
}
347347

@@ -371,9 +371,9 @@ pub(crate) fn is_subtype_of_typed(
371371
// namespace-qualified name and the other uses a short name
372372
// (e.g. `list<Pen>` vs `list<Demo\Pen>`). Re-check with the
373373
// class loader so nominal hierarchy applies to inner params.
374-
if let (PhpType::Generic(name_sub, args_sub), PhpType::Generic(name_sup, args_sup)) =
375-
(subtype, supertype)
376-
{
374+
if let (PhpType::Generic(sub), PhpType::Generic(sup)) = (subtype, supertype) {
375+
let (name_sub, args_sub) = (&sub.name, &sub.args);
376+
let (name_sup, args_sup) = (&sup.name, &sup.args);
377377
let base_sub = name_sub.to_ascii_lowercase();
378378
let base_sup = name_sup.to_ascii_lowercase();
379379
let bases_compatible = base_sub == base_sup
@@ -448,12 +448,12 @@ pub(crate) fn is_subtype_of_typed(
448448
// permissive (return true) to avoid false positives.
449449
if let PhpType::Literal(lit) = subtype
450450
&& lit.string_content().is_some()
451-
&& let PhpType::Generic(name, args) = supertype
452-
&& name.eq_ignore_ascii_case("model-property")
453-
&& args.len() == 1
451+
&& let PhpType::Generic(g) = supertype
452+
&& g.name.eq_ignore_ascii_case("model-property")
453+
&& g.args.len() == 1
454454
{
455455
let prop_name = lit.string_content().unwrap();
456-
if let Some(model_name) = args[0].base_name()
456+
if let Some(model_name) = g.args[0].base_name()
457457
&& let Some(cls) = class_loader(model_name)
458458
{
459459
let resolved = crate::inheritance::resolve_class_with_inheritance(&cls, class_loader);
@@ -473,7 +473,7 @@ pub(crate) fn is_subtype_of_typed(
473473
// content cannot be resolved to a class — it may simply live in
474474
// a file we haven't indexed — and only reject when the resolved
475475
// class provably fails to satisfy the bound.
476-
if let PhpType::Literal(crate::php_type::LiteralValue::String(_)) = subtype
476+
if let Some(crate::php_type::LiteralValue::String(_)) = subtype.as_literal()
477477
&& matches!(
478478
supertype,
479479
PhpType::ClassString(_) | PhpType::InterfaceString(_)

src/code_actions/phpstan/add_iterable_type.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ fn extract_iterable_return_type(message: &str) -> Option<PhpType> {
8080
/// Uses PHPStan's generic syntax: `array<string>`, `iterable<User>`,
8181
/// `Traversable<mixed>`, `array<int, string>`, etc.
8282
fn build_return_type(iterable_type: &str, args: Vec<PhpType>) -> PhpType {
83-
PhpType::Generic(iterable_type.to_owned(), args)
83+
PhpType::generic(iterable_type, args)
8484
}
8585

8686
// ── Docblock helpers ────────────────────────────────────────────────────────
@@ -548,9 +548,9 @@ impl Backend {
548548

549549
// Try to extract the generic parameter(s) from the inferred type.
550550
// e.g. `list<string>` → [string], `array<int, User>` → [int, User]
551-
if let PhpType::Generic(_, args) = &parsed {
552-
if !args.is_empty() && args.iter().all(|a| !a.is_mixed()) {
553-
return Some(args.clone());
551+
if let PhpType::Generic(g) = &parsed {
552+
if !g.args.is_empty() && g.args.iter().all(|a| !a.is_mixed()) {
553+
return Some(g.args.clone());
554554
}
555555
return None;
556556
}

src/code_actions/phpstan/fix_return_type/inference.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ pub(crate) fn infer_return_type(
237237
let effective = if deduped.len() == 1 {
238238
deduped.into_iter().next().unwrap()
239239
} else if deduped.len() <= 3 {
240-
PhpType::Union(deduped)
240+
PhpType::union(deduped)
241241
} else {
242242
return None;
243243
};

src/code_actions/phpstan/remove_unused_return_type.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,9 +135,9 @@ fn format_type_list(types: &[&PhpType], sep: &str) -> PhpType {
135135
return cloned.into_iter().next().unwrap();
136136
}
137137
if sep == "&" {
138-
PhpType::Intersection(cloned)
138+
PhpType::intersection(cloned)
139139
} else {
140-
PhpType::Union(cloned)
140+
PhpType::union(cloned)
141141
}
142142
}
143143

0 commit comments

Comments
 (0)