Skip to content

Commit c64b2e6

Browse files
committed
perf(vm): cache Lisp-visible symbol names per heap
Repeated symbol operands acquired the global registry read lock and probed name-object storage on every comparison. Cache the typed name view by symbol ID on the current heap, with a publication epoch for lazily materialized names. Keep mutable name objects live through their existing registry roots, replace the map on heap changes, and fall back to the registry when thread-local destruction makes the cache unavailable. Use a map so repeated scans retain their working set: a fixed-slot trial regressed scans larger than its capacity. Cover live mutation, exact GC, heap changes, dump/unintern behavior, warmed large scans and thread exit. Against d23d9f8, native symbol operand loops improve 1.51–1.65x and scans of 4–2048 names improve 1.46–1.47x. Whole Org/indent workloads are neutral; three isolated native query controls remain 3–10% slower, an explicit tradeoff. GNU comparisons use bytecode, without native compilation. Validation: 10,343 core tests passed (54 skipped), 79 focused GC-stress and partition tests passed, 48 byte compilations succeeded with matching retained normalized outputs, and formatting passed. Detailed measurements and rejected-candidate evidence are in tmp/vm-symbol-names-2026-09-21/.
1 parent 5c93ab6 commit c64b2e6

2 files changed

Lines changed: 187 additions & 3 deletions

File tree

  • crates/neovm-core/src/emacs_core/runtime/intern

‎crates/neovm-core/src/emacs_core/runtime/intern/mod.rs‎

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1433,9 +1433,82 @@ pub(crate) fn unintern_canonical_id(id: SymId) -> bool {
14331433
/// (s1)`.
14341434
#[inline]
14351435
pub(crate) fn resolve_lisp_visible_symbol_name(id: SymId) -> LispVisibleSymbolName {
1436-
global_symbol_registry()
1437-
.read()
1438-
.resolve_lisp_visible_name(id)
1436+
let heap_id = crate::tagged::gc::current_tagged_heap_identity().map(SymbolNameHeapId);
1437+
let epoch = SYMBOL_NAME_MATERIALIZATION_EPOCH.load(Ordering::Acquire);
1438+
let cached = VISIBLE_SYMBOL_NAME_CACHE
1439+
.try_with(|cache| {
1440+
let cache = cache.borrow();
1441+
if cache.heap_id != heap_id {
1442+
return None;
1443+
}
1444+
cache.names.get(&id).copied()
1445+
})
1446+
.ok()
1447+
.flatten();
1448+
if let Some(cached) = cached
1449+
&& cached.epoch == epoch
1450+
{
1451+
return cached.name;
1452+
}
1453+
resolve_lisp_visible_symbol_name_uncached(id, heap_id)
1454+
}
1455+
1456+
#[derive(Clone, Copy)]
1457+
struct VisibleSymbolNameCacheEntry {
1458+
epoch: u64,
1459+
name: LispVisibleSymbolName,
1460+
}
1461+
1462+
// Cache only names read on the current heap, without allocating a dense array
1463+
// up to the largest process-global symbol ID. A fixed-slot cache made repeated
1464+
// scans larger than its capacity slower by taking the miss path on every read.
1465+
// Retain the working set instead, releasing its storage when the heap changes.
1466+
// Store typed values, never borrowed heap strings: mutation stays live on hits.
1467+
#[derive(Default)]
1468+
struct VisibleSymbolNameCache {
1469+
heap_id: Option<SymbolNameHeapId>,
1470+
names: FxHashMap<SymId, VisibleSymbolNameCacheEntry>,
1471+
}
1472+
1473+
thread_local! {
1474+
static VISIBLE_SYMBOL_NAME_CACHE: RefCell<VisibleSymbolNameCache> =
1475+
RefCell::new(VisibleSymbolNameCache::default());
1476+
}
1477+
1478+
// The only transition of an existing (heap, symbol) name is Atom -> LispObject.
1479+
// Exact names are installed when a fresh symbol ID is allocated; unintern and
1480+
// dump restoration never replace an existing ID's name. Publishing a lazy name
1481+
// object invalidates atom views on every thread before releasing the write lock.
1482+
// Heap IDs are unique across heap lifetimes. Object views are already rooted by
1483+
// the registry's per-heap index and the collector does not move their objects.
1484+
static SYMBOL_NAME_MATERIALIZATION_EPOCH: AtomicU64 = AtomicU64::new(0);
1485+
1486+
#[inline(never)]
1487+
fn resolve_lisp_visible_symbol_name_uncached(
1488+
id: SymId,
1489+
heap_id: Option<SymbolNameHeapId>,
1490+
) -> LispVisibleSymbolName {
1491+
let registry = global_symbol_registry().read();
1492+
let name = registry.resolve_lisp_visible_name(id);
1493+
// Pair the epoch with the resolved view under the same lock. Reading it
1494+
// after unlocking could label an old atom with a newly materialized epoch.
1495+
let epoch = SYMBOL_NAME_MATERIALIZATION_EPOCH.load(Ordering::Acquire);
1496+
drop(registry);
1497+
// Diagnostics can resolve names after this cache's thread-local destructor
1498+
// has run. The registry remains available, so caching is optional there.
1499+
let _ = VISIBLE_SYMBOL_NAME_CACHE.try_with(|cache| {
1500+
let mut cache = cache.borrow_mut();
1501+
if cache.heap_id != heap_id {
1502+
*cache = VisibleSymbolNameCache {
1503+
heap_id,
1504+
names: FxHashMap::default(),
1505+
};
1506+
}
1507+
cache
1508+
.names
1509+
.insert(id, VisibleSymbolNameCacheEntry { epoch, name });
1510+
});
1511+
name
14391512
}
14401513

14411514
/// Return the one Lisp name object for `id` in the current tagged heap.
@@ -1478,6 +1551,7 @@ pub(crate) fn materialize_symbol_name_value(id: SymId) -> TaggedValue {
14781551
value: materialized,
14791552
heap_id,
14801553
});
1554+
SYMBOL_NAME_MATERIALIZATION_EPOCH.fetch_add(1, Ordering::Release);
14811555
materialized
14821556
}
14831557

‎crates/neovm-core/src/emacs_core/runtime/intern/tests/mod.rs‎

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,73 @@ fn visible_symbol_name_reads_follow_materialization_mutation_and_gc() {
184184
}
185185
}
186186

187+
#[test]
188+
fn visible_symbol_name_reads_reuse_registry_lookup_until_materialization() {
189+
let mut heap = crate::tagged::gc::TaggedHeap::new();
190+
crate::tagged::gc::set_tagged_heap(&mut heap);
191+
let symbol = intern("visible-name-registry-read-probe");
192+
reset_symbol_name_value_probes();
193+
for _ in 0..64 {
194+
std::hint::black_box(resolve_lisp_visible_symbol_name(symbol));
195+
}
196+
assert_eq!(symbol_name_value_probes(), (0, 1));
197+
198+
let name = materialize_symbol_name_value(symbol);
199+
reset_symbol_name_value_probes();
200+
for _ in 0..64 {
201+
assert!(matches!(
202+
resolve_lisp_visible_symbol_name(symbol),
203+
LispVisibleSymbolName::LispObject(value) if value.bits() == name.bits()
204+
));
205+
}
206+
assert_eq!(symbol_name_value_probes(), (0, 1));
207+
208+
let exact_name = TaggedValue::string("visible-name-exact-registry-read-probe");
209+
let exact_symbol = make_uninterned_symbol_with_name_value(exact_name);
210+
reset_symbol_name_value_probes();
211+
for _ in 0..64 {
212+
assert!(matches!(
213+
resolve_lisp_visible_symbol_name(exact_symbol),
214+
LispVisibleSymbolName::LispObject(value) if value.bits() == exact_name.bits()
215+
));
216+
}
217+
assert_eq!(symbol_name_value_probes(), (1, 0));
218+
}
219+
220+
#[test]
221+
fn visible_symbol_name_reads_reuse_a_large_working_set() {
222+
let mut heap = crate::tagged::gc::TaggedHeap::new();
223+
crate::tagged::gc::set_tagged_heap(&mut heap);
224+
let symbols: Vec<_> = (0..1024)
225+
.map(|i| {
226+
let spelling = format!("visible-name-replacement-{i}");
227+
let symbol = intern_uninterned(&spelling);
228+
if i % 2 == 0 {
229+
materialize_symbol_name_value(symbol);
230+
}
231+
(symbol, spelling)
232+
})
233+
.collect();
234+
for pass in 0..2 {
235+
reset_symbol_name_value_probes();
236+
for (symbol, spelling) in &symbols {
237+
assert_eq!(
238+
resolve_lisp_visible_symbol_name(*symbol)
239+
.text()
240+
.as_utf8_str(),
241+
Some(spelling.as_str())
242+
);
243+
}
244+
if pass == 1 {
245+
assert_eq!(
246+
symbol_name_value_probes(),
247+
(0, 0),
248+
"a warmed scan must not thrash a fixed number of cache slots"
249+
);
250+
}
251+
}
252+
}
253+
187254
#[test]
188255
fn visible_symbol_name_reads_follow_heap_switch_and_drop() {
189256
let mut first_heap = Box::new(crate::tagged::gc::TaggedHeap::new());
@@ -226,6 +293,49 @@ fn visible_symbol_name_reads_follow_heap_switch_and_drop() {
226293
));
227294
}
228295

296+
#[test]
297+
fn visible_symbol_name_reads_survive_thread_cache_destruction() {
298+
use std::sync::Arc;
299+
use std::sync::atomic::AtomicBool;
300+
301+
struct ReadNameOnDrop {
302+
symbol: SymId,
303+
checked: Arc<AtomicBool>,
304+
}
305+
impl Drop for ReadNameOnDrop {
306+
fn drop(&mut self) {
307+
assert_eq!(
308+
resolve_lisp_visible_symbol_name(self.symbol)
309+
.text()
310+
.as_utf8_str(),
311+
Some("visible-name-thread-exit-probe")
312+
);
313+
self.checked.store(true, Ordering::Relaxed);
314+
}
315+
}
316+
thread_local! {
317+
static READ_ON_DROP: RefCell<Option<ReadNameOnDrop>> = const { RefCell::new(None) };
318+
}
319+
320+
let checked = Arc::new(AtomicBool::new(false));
321+
let on_thread = Arc::clone(&checked);
322+
std::thread::spawn(move || {
323+
let symbol = intern_uninterned("visible-name-thread-exit-probe");
324+
// TLS destructors run in reverse initialization order. Register the
325+
// diagnostic first so its read runs after the name cache is destroyed.
326+
READ_ON_DROP.with(|slot| {
327+
*slot.borrow_mut() = Some(ReadNameOnDrop {
328+
symbol,
329+
checked: on_thread,
330+
});
331+
});
332+
std::hint::black_box(resolve_lisp_visible_symbol_name(symbol));
333+
})
334+
.join()
335+
.unwrap();
336+
assert!(checked.load(Ordering::Relaxed));
337+
}
338+
229339
#[test]
230340
fn visible_symbol_name_reads_preserve_identity_after_unintern_and_restore() {
231341
let mut heap = crate::tagged::gc::TaggedHeap::new();

0 commit comments

Comments
 (0)