Skip to content

Commit 273935a

Browse files
committed
Identical types are stored once
1 parent d816150 commit 273935a

104 files changed

Lines changed: 2743 additions & 2464 deletions

File tree

Some content is hidden

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

docs/ARCHITECTURE.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,8 @@ project for PHP parsing and type representation:
151151
| `mago-names` | Use-statement and namespace resolution |
152152
| `mago-type-syntax` | PHPStan/Psalm type expression parsing into `PhpType` |
153153

154-
`PhpType` (`src/php_type.rs`) is an owned enum that wraps the borrowed
154+
`PhpType` (`src/php_type/`) is a pointer-sized handle to an interned
155+
`TypeKind` node, itself built from the borrowed
155156
`mago_type_syntax::cst::Type` AST. Every type-carrying field in the
156157
data model (`type_hint`, `return_type`, `native_type_hint`,
157158
`native_return_type`, `asserted_type`, `template_param_bounds` values,
@@ -160,6 +161,20 @@ The substitution engine in `inheritance.rs` operates on
160161
`HashMap<String, PhpType>` so that template parameter replacement
161162
never re-parses type strings.
162163

164+
Types are hash-consed: every constructor routes through the interner in
165+
`php_type/intern.rs`, so two structurally equal types always share one
166+
allocation. That is what lets a type occurrence cost 8 bytes rather
167+
than 24 plus a private copy of its payload on a workload that repeats
168+
the same few thousand forms everywhere, and it makes `PartialEq` a
169+
pointer comparison instead of a structural walk. Match on
170+
`PhpType::kind()` to inspect a node, and build new ones through the
171+
constructors (`PhpType::named`, `PhpType::nullable`, `PhpType::union`,
172+
…) rather than by constructing a `TypeKind` directly, so the pointer
173+
and structural notions of equality never drift apart. The table holds
174+
weak references, so forms nothing refers to any more are reclaimed:
175+
`TypeKind::Raw` and `TypeKind::Literal` carry free text, which would
176+
otherwise grow without bound in a long-running server.
177+
163178
Several other Mago crates were evaluated and ruled out:
164179

165180
| Crate | Reason |

docs/CHANGELOG.md

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

3838
- **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.
39+
- **Identical types are stored once.** A project repeats the same handful of types thousands of times over: every `string` parameter, every `?Carbon` property, every `Collection<User>` return. PHPantom now stores each distinct type a single time and refers to it by a pointer, so a type occurrence costs 8 bytes instead of 24 plus its own copy of the payload. On large Laravel projects this cuts peak memory by a further 10% and the live heap by 20%, and drops around a quarter of all live allocations. Comparing two types is now a pointer check rather than a walk over their structure, so analysis is slightly faster as well.
3940
- **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.
4041
- **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.
4142
- **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: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,6 @@ within the same impact tier.
2626
| # | Item | Impact | Effort |
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 |
29-
| P38 | [Resolved type values are duplicated ~34x](todo/performance.md#p38-resolved-type-values-are-duplicated-34x) | High | High |
30-
| 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 |
3129
| 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 |
3230
| 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 |
3331
| 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: 39 additions & 137 deletions
Original file line numberDiff line numberDiff line change
@@ -404,9 +404,10 @@ so this fast-path would apply to the majority of checks.
404404
`HashMap<(Atom, Atom), bool>` that caches the result of
405405
"is type A a subtype of type B?" lookups. Clear the map at the
406406
start of each completion/hover/diagnostic request. Class names
407-
are interned now (`PhpType::Named` carries an `Atom`, `ClassInfo`
407+
are interned now (`TypeKind::Named` carries an `Atom`, `ClassInfo`
408408
caches its FQN as an `Atom`), so the keys are `Copy` and
409-
identity-hashed.
409+
identity-hashed. Whole types are interned too, so a `(PhpType,
410+
PhpType)` key would work as well and hash just as cheaply.
410411

411412
2. Add a `has_template_params: bool` flag (or equivalent) to
412413
`ClassInfo` or type representations. Set it during parsing when
@@ -1036,16 +1037,16 @@ interface merge inside `resolve_class_fully_inner` are intentional
10361037
a CPU fix (11% of diagnostic-pass CPU), not a memory one, but it is
10371038
memory-neutral-to-positive: today each of the 25,676 calls builds a
10381039
full inheritance-merged `ClassInfo` (its own methods/properties vecs)
1039-
and immediately discards it, which is 25,676 transient allocations of
1040-
exactly the kind P41 cares about. Routing through the cache turns most
1041-
of those into a hit against an entry the diagnostic pass populates
1042-
anyway; it can only add a new persistent cache entry for a class that
1043-
was otherwise *never* resolved in the pass, which is a narrow case
1044-
bounded by the same fix as P33.
1040+
and immediately discards it25,676 transient allocations that
1041+
straight-line CPU savings would remove as a side effect. Routing
1042+
through the cache turns most of those into a hit against an entry the
1043+
diagnostic pass populates anyway; it can only add a new persistent
1044+
cache entry for a class that was otherwise *never* resolved in the
1045+
pass, which is a narrow case bounded by the same fix as P33.
10451046

10461047
---
10471048

1048-
## Memory baseline (2026-07-26, refreshed after the `PhpType` shrink)
1049+
## Memory baseline (2026-07-28, refreshed after `PhpType` interning)
10491050

10501051
Measured with the `mem-audit` cargo feature, which installs a counting
10511052
global allocator and prints a deep-size walk of every long-lived store
@@ -1061,8 +1062,8 @@ The walk dedups shared `Arc`s by pointer, so a member reached from
10611062
several classes is counted once. It reports both a field-level
10621063
attribution and a drop-and-measure probe that clears each store and
10631064
reads the exact allocator delta, which cross-checks the walk. Since
1064-
which allocator is active changes the RSS/overhead figures (see P41),
1065-
the audit labels its own build (`target … / … libc, allocator …`) —
1065+
which allocator is active changes the RSS/overhead figures, the audit
1066+
labels its own build (`target … / … libc, allocator …`) —
10661067
always check that line before comparing a new run to the table below.
10671068

10681069
The byte/allocation figures (live heap, per-store bytes released,
@@ -1075,39 +1076,50 @@ mimalloc, since that is what every shipped Linux binary runs (see
10751076

10761077
| Measure | Mid-size Laravel app | Large Laravel app |
10771078
| ----------------------------- | -------------------- | ----------------- |
1078-
| Peak RSS (`VmHWM`) | 505 MB | 706 MB |
1079-
| RSS after mimalloc collect | 477 MB | 686 MB |
1080-
| Live heap (allocator-reported)| 344 MB | 514 MB |
1081-
| Live allocations | 2.45 M | 3.81 M |
1082-
| Allocator + page overhead | 133 MB (28% of RSS) | 172 MB (25%) |
1079+
| Peak RSS (`VmHWM`) | 402 MB | 540 MB |
1080+
| RSS after mimalloc collect | 370 MB | 510 MB |
1081+
| Live heap (allocator-reported)| 245 MB | 358 MB |
1082+
| Live allocations | 1.44 M | 2.21 M |
1083+
| Allocator + page overhead | 125 MB (34% of RSS) | 152 MB (30%) |
10831084

10841085
The glibc figures below were taken against the original baseline (the
1085-
one with a 64-byte `PhpType`) and have not been re-measured since; the
1086-
relative picture they illustrate is unchanged. Under plain glibc
1086+
one with a 64-byte `PhpType`, before it was shrunk and then interned)
1087+
and have not been re-measured since; the relative picture they
1088+
illustrate is unchanged. Under plain glibc
10871089
`malloc` (no mimalloc) the same run peaked higher despite a *lower*
10881090
overhead percentage: 622 MB / 855 MB peak, 100 MB (19%) / 133 MB (17%)
10891091
overhead. mimalloc holds more
10901092
allocator/page overhead in percentage terms but a smaller absolute
10911093
peak, because PHPantom already tunes it (`configure_allocator`: a
10921094
short purge delay plus a per-process THP opt-out) to return freed
1093-
parse-burst memory promptly. See P41 for what's left after that tuning.
1095+
parse-burst memory promptly.
1096+
1097+
The overhead percentage rose as later changes (P33 sharing, P38
1098+
interning) cut the live-allocation count — evidence that this overhead
1099+
tracks the size of mimalloc's retained free-page pool, not a flat
1100+
per-allocation cost, and that pool is sized by the parse-burst peak
1101+
rather than the steady-state live set. Reducing allocation count on
1102+
its own is therefore not expected to move it further; a real
1103+
reduction would have to target that peak instead.
10941104

10951105
Bytes released per store (drop-and-measure, mid-size / large):
10961106

10971107
| Store | Released | Allocations |
10981108
| ---------------------- | ---------------- | --------------- |
1099-
| `resolved_class_cache` | 143 MB / 237 MB | 1.17 M / 1.94 M |
1100-
| `symbol_maps` | 40 MB / 64 MB | 367 K / 602 K |
1101-
| `reference_index` | 26 MB / 43 MB | 204 K / 330 K |
1102-
| `uri_classes_index` | 25 MB / 27 MB | 122 K / 171 K |
1109+
| `resolved_class_cache` | 101 MB / 165 MB | 719 K / 1.18 M |
1110+
| `symbol_maps` | 30 MB / 47 MB | 159 K / 248 K |
1111+
| `uri_classes_index` | 16 MB / 23 MB | 104 K / 149 K |
1112+
| `reference_index` | 5.3 MB / 8.1 MB | 73 K / 112 K |
11031113
| `resolved_names` | 4.6 MB / 6.7 MB | 53 K / 75 K |
11041114
| everything else | < 6 MB each ||
11051115

11061116
Within the member data, the largest field-level groups on the large app
1107-
are `MethodInfo` structs (92 MB across 232 K distinct methods at 400 B
1108-
each), parameter vectors (51 MB across 191 K vectors holding 325 K
1109-
parameters at 136 B each), `PhpType` heap payloads (~33 MB), and
1110-
`method_index` (21 MB).
1117+
are `MethodInfo` structs (78 MB across 232 K distinct methods at 336 B
1118+
each), parameter vectors (36 MB across 191 K vectors holding 325 K
1119+
parameters at 88 B each), member docblock text (13 MB), and
1120+
`method_index` (12 MB). Type payloads no longer register: interning
1121+
collapsed 856 K live type occurrences on that app to 51 K distinct
1122+
nodes, so the whole type representation now costs under 4 MB.
11111123

11121124
Two findings from the baseline are worth recording because they close
11131125
off obvious-looking ideas:
@@ -1125,116 +1137,6 @@ off obvious-looking ideas:
11251137

11261138
---
11271139

1128-
## P38. Resolved type values are duplicated ~34x
1129-
1130-
**Impact: High · Effort: High**
1131-
1132-
The memory audit renders every live `PhpType` and counts distinct
1133-
forms: **1.76 M values collapse to 51,272 distinct rendered types** on
1134-
the large Laravel app, and 1.07 M to 31,074 on the mid-size one. Both
1135-
are almost exactly **34x duplication**. Every `string`, `?Carbon`,
1136-
`Collection<User>`, and `Builder<TModel>` is re-allocated per method,
1137-
per parameter, per class that inherits it.
1138-
1139-
Interning types the way `Atom` interns names would collapse both halves
1140-
of the remaining cost at once: each slot becomes a pointer-or-index
1141-
handle (24 B down to 8 B, worth another ~24 MB of slot bytes on the
1142-
large app on its own), and each distinct type is allocated once instead
1143-
of ~34 times. The audit's "PhpType 24→16 B" projection covers only the
1144-
slot half; the duplicate payloads are the larger share.
1145-
1146-
This is a much larger change than the slot shrink that preceded it:
1147-
`PhpType` is a value that substitution paths mutate in place, so
1148-
interning means moving fully to a construct-and-intern API (the
1149-
`PhpType::union(...)` / `PhpType::generic(...)` constructors added by
1150-
the slot shrink are the beginning of that surface) and auditing every
1151-
site that builds or rewrites a type.
1152-
1153-
Take care not to feed the interner unbounded text: `Raw` holds
1154-
free-text parse fallbacks and `Literal` holds source literals, so those
1155-
should keep owned payloads or use a separate bounded cache.
1156-
1157-
---
1158-
1159-
## P41. 3.8 M live allocations cost ~170 MB in allocator overhead
1160-
1161-
**Impact: Medium · Effort: Medium**
1162-
1163-
Independent of what we store, *how many* allocations we hold costs
1164-
real memory. On the large Laravel app, mimalloc (the allocator every
1165-
shipped Linux binary runs — see the "Memory baseline" note above)
1166-
reports 514 MB live across 3.81 M allocations (average 142 B), while
1167-
peak RSS is 706 MB. The 172 MB difference (25% of RSS; 28% on the
1168-
mid-size app) is mimalloc's per-size-class page and slot rounding —
1169-
roughly 45 B per live allocation.
1170-
1171-
This is *after* the existing `configure_allocator` tuning (a short
1172-
purge delay plus a per-process transparent-huge-pages opt-out — see
1173-
`src/lib.rs`), which already earns back real RSS: the same run under
1174-
plain glibc `malloc` peaks *higher* (622 MB / 855 MB) despite a lower
1175-
overhead percentage (19% / 17%), because glibc holds freed arena
1176-
pages far more readily than mimalloc does once tuned. So the
1177-
allocator swap this item used to propose is already done — mimalloc is
1178-
the shipped allocator and it is outperforming the alternative on
1179-
absolute peak RSS. `MALLOC_ARENA_MAX` capping under plain glibc moves
1180-
peak RSS (622 MB to 516 MB on the mid-size app) but barely moves the
1181-
trimmed live figure, confirming arena count was never the lever.
1182-
1183-
What is left is allocation *count* itself: at ~45 B of overhead per
1184-
live allocation, each allocation avoided is worth more than its own
1185-
`size_of`. P39 removed hundreds of thousands of allocations as a side
1186-
effect (interning `SymbolKind` strings).
1187-
1188-
**2026-07-27 re-measurement.** With P39 landed, re-running the
1189-
`mem-audit` walk turned up a genuine leak in the assertion-narrowing
1190-
path (`src/type_engine/types/narrowing/assertions.rs`): every
1191-
`@phpstan-assert`/`@psalm-assert` call-site evaluation `Box::leak`ed a
1192-
cloned `FunctionInfo` or `MethodInfo` to get a stable reference for
1193-
`CallAssertionInfo`, and neither clone was ever freed. Narrowing runs
1194-
on every conditional the forward walker visits, so this leaked once
1195-
per asserting call touched during a pass, for the life of the
1196-
process — worse in a long-running LSP session (re-run on every
1197-
keystroke) than in one-shot `analyze`. Fixed by having
1198-
`CallAssertionInfo` own the four fields it actually needs (an
1199-
`Arc`-shared parameter vector plus three small `Vec`s) instead of
1200-
leaking the whole clone.
1201-
1202-
Re-measured on four real projects (one small, two mid-size, one
1203-
large) with the fixed build: live allocations dropped 7-12% on the
1204-
three larger projects and 53% on the small one, which spends
1205-
proportionally more of its pass time in narrowing relative to its
1206-
total symbol count. Live bytes dropped correspondingly (tens of MB on
1207-
the larger projects). The allocator-overhead *percentage* barely
1208-
moved — mimalloc's rounding overhead per allocation is roughly
1209-
constant regardless of which allocations are removed, so cutting
1210-
count shrinks both the overhead and the total footprint by a similar
1211-
proportion. This confirms the mechanism above without yet closing the
1212-
gap enough to retire this item.
1213-
1214-
While re-measuring, the audit's drop-and-measure probes (`src/mem_audit.rs`)
1215-
were extended to cover every remaining `Backend` store (the phar
1216-
archive cache, autoload/stub indexes, workspace PSR-4/vendor paths,
1217-
diagnostic pull-model caches, and more). Previously 18-26% of live
1218-
heap was an unattributed gap between the field-level walk and the
1219-
allocator's own count; the probes now account for nearly all of it, so
1220-
future passes on this item measure against a real residual (dominated
1221-
by the `ustr` name interner and short-lived per-request state) instead
1222-
of an unexplained shortfall. One find from the newly-covered stores:
1223-
`phar_archives` (raw phar bytes plus a byte-offset index, kept for lazy
1224-
re-extraction of phar-embedded PHP files such as vendor-shipped
1225-
PHPStan) held on the order of 25 MB on projects with a phar-shaped
1226-
dependency, previously invisible to the audit.
1227-
1228-
If a meaningful gap remains once other in-flight allocation-count
1229-
reductions land, the next lever is arena-allocating the member
1230-
metadata (bump-allocate `MethodInfo`/`PropertyInfo`/`ParameterInfo` per
1231-
resolved class instead of one `Box`/`Arc` per member) — a much larger
1232-
change than anything else in this list, and today's re-measurement
1233-
does not yet show the overhead percentage moving enough to justify it
1234-
on its own.
1235-
1236-
---
1237-
12381140
## P42. Reference index is built during the headless `analyze` pipeline but never queried there
12391141

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

docs/todo/type-inference.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,7 @@ The remaining gap is that *unannotated* closures like
308308
`$fn = function(int $x): string { ... }` resolve to bare `Closure`
309309
with no signature detail. `infer_closure_literal_type` (in
310310
`rhs_resolution.rs`) already embeds the inferred return type in a
311-
`PhpType::Callable`, but always leaves `params` empty, so it does not
311+
`TypeKind::Callable`, but always leaves `params` empty, so it does not
312312
produce a full callable type string for variable-type contexts.
313313

314314
**Example:**

0 commit comments

Comments
 (0)