Skip to content

Commit 4cf7628

Browse files
committed
Intern composite per-member cache keys
1 parent c381e3e commit 4cf7628

4 files changed

Lines changed: 41 additions & 34 deletions

File tree

docs/todo/eager-resolution.md

Lines changed: 15 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -367,27 +367,21 @@ checks, `base_name` extraction) were unaffected; only genuine
367367
`String`-typed sinks (`Vec<String>` keys, `Generic`'s `String` field)
368368
needed an explicit `.to_string()`.
369369

370-
#### Phase 4h: Intern composite per-member cache keys
371-
372-
**Impact: Low. Effort: Low.**
373-
374-
`target_cache.rs` (`BODY_INFER_MEMO`, the re-entry set) and
375-
`property_access.rs` key thread-local caches on
376-
`format!("{}::{}", class_fqn, method)`. These allocate a `String` per
377-
lookup. Interning them into a single `Atom` key would remove the
378-
allocation, but the key space is the `FQN × member` cartesian product,
379-
which is effectively unbounded — feeding it to the global `ustr`
380-
interner would leak one entry per distinct pair for the process
381-
lifetime (the exact "constructed once, never compared" case
382-
`atom.rs` warns against). Any fix here should therefore use a
383-
composite `(Atom, Atom)` tuple key (both halves already interned,
384-
no new interner entries) rather than interning the joined string.
385-
386-
**Success criteria:**
387-
- The `"FQN::member"` cache keys no longer allocate a `String` per
388-
lookup.
389-
- No net growth in the global interner.
390-
- No test regressions.
370+
#### Phase 4h: Intern composite per-member cache keys ✓
371+
372+
`target_cache.rs` (`BODY_INFER_MEMO` and its `BODY_INFER_VISITED`
373+
re-entry set) and `property_access.rs` (`RESOLVING_THIS_PROP`) keyed
374+
thread-local caches on `format!("{}::{}", class_fqn, member)`, allocating
375+
a `String` per lookup. These now use a composite `(Atom, Atom)` tuple
376+
key. Both halves are drawn from bounded symbol-name spaces (class FQNs
377+
and member identifiers) and are already interned, so the tuple key
378+
allocates nothing per lookup and adds no new entries to the global
379+
interner. Interning the *joined* string instead would have leaked one
380+
entry per distinct `FQN × member` pair for the process lifetime (the
381+
"constructed once, never compared" case `atom.rs` warns against), so the
382+
tuple is the correct shape. `CALLABLE_TARGET_CACHE` is intentionally left
383+
on a string key: its key embeds generic argument text
384+
(`"FQN<generics>::method"`) and does not fit a two-`Atom` tuple.
391385

392386
---
393387

src/type_engine/call_resolution/target_cache.rs

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use std::cell::{Cell, RefCell};
77
use std::collections::{HashMap, HashSet};
88

99
use crate::Backend;
10+
use crate::atom::{Atom, atom};
1011
use crate::php_type::PhpType;
1112
use crate::types::*;
1213

@@ -26,6 +27,9 @@ type BodyReturnInferrerFn = Box<dyn Fn(&str, &MethodInfo) -> Option<PhpType>>;
2627
/// down.
2728
type AuthUserResolverFn = Box<dyn Fn(Option<&str>) -> Option<PhpType>>;
2829

30+
/// Memoized body-return-inference results, keyed by `(FQN, method)`.
31+
type BodyInferMemo = HashMap<(Atom, Atom), Option<PhpType>>;
32+
2933
thread_local! {
3034
/// When `Some`, `resolve_instance_method_callable` caches results
3135
/// by `"FQN::method_lower"`. Activated by
@@ -44,14 +48,14 @@ thread_local! {
4448
const { RefCell::new(None) };
4549

4650
/// Re-entry guard for body return inference. Tracks
47-
/// `"FQN::method"` keys currently being inferred to prevent
51+
/// `(FQN, method)` keys currently being inferred to prevent
4852
/// infinite recursion when a method body references another
4953
/// method that also lacks a return type.
50-
static BODY_INFER_VISITED: RefCell<HashSet<String>> =
54+
static BODY_INFER_VISITED: RefCell<HashSet<(Atom, Atom)>> =
5155
RefCell::new(HashSet::new());
5256

5357
/// When `Some`, memoizes completed body return inference results by
54-
/// `"FQN::method"`. Activated together with [`BODY_RETURN_INFERRER`]
58+
/// `(FQN, method)`. Activated together with [`BODY_RETURN_INFERRER`]
5559
/// and cleared when the owning guard drops, so the memo lives exactly
5660
/// as long as one request / one file's diagnostic pass.
5761
///
@@ -60,7 +64,7 @@ thread_local! {
6064
/// files where most methods lack declared return types, the repeated
6165
/// walks compound into a multi-minute blowup (each walk itself
6266
/// triggers inference for the callees it contains).
63-
static BODY_INFER_MEMO: RefCell<Option<HashMap<String, Option<PhpType>>>> =
67+
static BODY_INFER_MEMO: RefCell<Option<BodyInferMemo>> =
6468
const { RefCell::new(None) };
6569

6670
/// Current nesting depth of body return inference. Caps the
@@ -176,8 +180,12 @@ pub(crate) fn with_body_return_inferrer(inferrer: BodyReturnInferrerFn) -> BodyR
176180
const MAX_BODY_INFER_DEPTH: u8 = 3;
177181

178182
pub(crate) fn try_infer_body_return_type(class_fqn: &str, method: &MethodInfo) -> Option<PhpType> {
179-
// Build the memo / re-entry key.
180-
let key = format!("{}::{}", class_fqn, method.name);
183+
// Build the memo / re-entry key as an interned `(FQN, method)`
184+
// tuple. Both halves come from bounded symbol-name spaces and are
185+
// already interned, so the key allocates nothing and adds no new
186+
// entries to the global interner (a joined `"FQN::method"` string
187+
// would leak one entry per distinct pair for the process lifetime).
188+
let key = (atom(class_fqn), method.name);
181189

182190
// Serve a memoized result from an earlier completed inference in
183191
// this request. Checked before the depth cap so that deep call
@@ -197,7 +205,7 @@ pub(crate) fn try_infer_body_return_type(class_fqn: &str, method: &MethodInfo) -
197205
// Check + insert into the visited set (re-entry guard).
198206
let already_visiting = BODY_INFER_VISITED.with(|cell| {
199207
let mut set = cell.borrow_mut();
200-
!set.insert(key.clone())
208+
!set.insert(key)
201209
});
202210
if already_visiting {
203211
return None;

src/type_engine/variable/rhs_resolution/property_access.rs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use std::sync::Arc;
99
use mago_span::HasSpan;
1010
use mago_syntax::cst::*;
1111

12-
use crate::atom::{atom, bytes_to_str};
12+
use crate::atom::{Atom, atom, bytes_to_str};
1313
use crate::parser::with_parsed_program;
1414
use crate::php_type::PhpType;
1515
use crate::types::{ClassInfo, ResolvedType};
@@ -433,11 +433,16 @@ pub(super) fn try_resolve_this_property_from_assignment(
433433
// resolved, return empty so the caller falls back to the declared
434434
// property type instead of recursing.
435435
thread_local! {
436-
static RESOLVING_THIS_PROP: std::cell::RefCell<HashSet<String>> =
436+
static RESOLVING_THIS_PROP: std::cell::RefCell<HashSet<(Atom, Atom)>> =
437437
std::cell::RefCell::new(HashSet::new());
438438
}
439-
let key = format!("{}::{}", ctx.current_class.name, prop_name);
440-
let newly_inserted = RESOLVING_THIS_PROP.with(|set| set.borrow_mut().insert(key.clone()));
439+
// Key on `(class, prop)` as interned atoms rather than a joined
440+
// `"Class::prop"` string: both halves are already interned, so the
441+
// tuple key allocates nothing per lookup and adds no new entries to
442+
// the global interner (the joined string would leak one entry per
443+
// distinct pair for the process lifetime).
444+
let key = (ctx.current_class.name, atom(prop_name));
445+
let newly_inserted = RESOLVING_THIS_PROP.with(|set| set.borrow_mut().insert(key));
441446
if !newly_inserted {
442447
// Same property is already mid-resolution: break the cycle.
443448
return Vec::new();

src/virtual_members/laravel/config_values.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ use mago_database::file::FileId;
2626
use mago_syntax::cst::*;
2727

2828
use crate::Backend;
29-
use crate::atom::bytes_to_str;
29+
use crate::atom::{atom, bytes_to_str};
3030
use crate::php_type::{PhpType, ShapeEntry};
3131

3232
/// A statically-classified Laravel config value.
@@ -142,7 +142,7 @@ impl ConfigValue {
142142
ConfigValue::Bool => PhpType::bool(),
143143
ConfigValue::Null => PhpType::null(),
144144
ConfigValue::ClassString(name) => {
145-
PhpType::ClassString(Some(Box::new(PhpType::Named(name.clone()))))
145+
PhpType::ClassString(Some(Box::new(PhpType::Named(atom(name)))))
146146
}
147147
ConfigValue::OneOf(arms) => {
148148
let mut members: Vec<PhpType> = Vec::new();

0 commit comments

Comments
 (0)