Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 6a6ba5c

Browse files
committedMar 14, 2025
Lazy-chunk Symbol interner
This fixes unsoundness in the Symbol::as_str by leaking the chunks (via the static memory).
1 parent cbfdf0b commit 6a6ba5c

File tree

5 files changed

+187
-52
lines changed

5 files changed

+187
-52
lines changed
 

‎Cargo.lock

+2-1
Original file line numberDiff line numberDiff line change
@@ -1491,7 +1491,6 @@ version = "0.15.2"
14911491
source = "registry+https://github.com/rust-lang/crates.io-index"
14921492
checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289"
14931493
dependencies = [
1494-
"allocator-api2",
14951494
"foldhash",
14961495
"serde",
14971496
]
@@ -4406,6 +4405,8 @@ version = "0.0.0"
44064405
dependencies = [
44074406
"blake3",
44084407
"derive-where",
4408+
"elsa",
4409+
"hashbrown 0.15.2",
44094410
"indexmap",
44104411
"itoa",
44114412
"md-5",

‎compiler/rustc_span/Cargo.toml

+2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ edition = "2024"
77
# tidy-alphabetical-start
88
blake3 = "1.5.2"
99
derive-where = "1.2.7"
10+
elsa = "1.11.0"
11+
hashbrown = { version = "0.15.2", default-features = false }
1012
indexmap = { version = "2.0.0" }
1113
itoa = "1.0"
1214
md5 = { package = "md-5", version = "0.10.0" }

‎compiler/rustc_span/src/lib.rs

+5
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,19 @@
2424
#![feature(core_io_borrowed_buf)]
2525
#![feature(hash_set_entry)]
2626
#![feature(if_let_guard)]
27+
#![feature(lazy_get)]
2728
#![feature(let_chains)]
2829
#![feature(map_try_insert)]
2930
#![feature(negative_impls)]
31+
#![feature(new_zeroed_alloc)]
3032
#![feature(read_buf)]
3133
#![feature(round_char_boundary)]
3234
#![feature(rustc_attrs)]
3335
#![feature(rustdoc_internals)]
3436
#![feature(slice_as_chunks)]
37+
#![feature(str_from_raw_parts)]
38+
#![feature(sync_unsafe_cell)]
39+
#![warn(unreachable_pub)]
3540
// tidy-alphabetical-end
3641

3742
// The code produced by the `Encodable`/`Decodable` derive macros refer to

‎compiler/rustc_span/src/symbol.rs

+171-46
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22
//! allows bidirectional lookup; i.e., given a value, one can easily find the
33
//! type, and vice versa.
44
5-
use std::hash::{Hash, Hasher};
5+
use std::cell::SyncUnsafeCell;
6+
use std::hash::{BuildHasher, BuildHasherDefault, Hash, Hasher};
7+
use std::sync::LazyLock;
8+
use std::sync::atomic::{AtomicU32, Ordering};
69
use std::{fmt, str};
710

8-
use rustc_arena::DroplessArena;
9-
use rustc_data_structures::fx::FxIndexSet;
11+
use rustc_data_structures::fx::FxHasher;
1012
use rustc_data_structures::stable_hasher::{
1113
HashStable, StableCompare, StableHasher, ToStableHashKey,
1214
};
@@ -2521,18 +2523,9 @@ impl Symbol {
25212523
with_session_globals(|session_globals| session_globals.symbol_interner.intern(string))
25222524
}
25232525

2524-
/// Access the underlying string. This is a slowish operation because it
2525-
/// requires locking the symbol interner.
2526-
///
2527-
/// Note that the lifetime of the return value is a lie. It's not the same
2528-
/// as `&self`, but actually tied to the lifetime of the underlying
2529-
/// interner. Interners are long-lived, and there are very few of them, and
2530-
/// this function is typically used for short-lived things, so in practice
2531-
/// it works out ok.
2526+
/// Access the underlying string.
25322527
pub fn as_str(&self) -> &str {
2533-
with_session_globals(|session_globals| unsafe {
2534-
std::mem::transmute::<&str, &str>(session_globals.symbol_interner.get(*self))
2535-
})
2528+
with_session_globals(|session_globals| session_globals.symbol_interner.get(*self))
25362529
}
25372530

25382531
pub fn as_u32(self) -> u32 {
@@ -2587,53 +2580,185 @@ impl StableCompare for Symbol {
25872580
}
25882581
}
25892582

2590-
pub(crate) struct Interner(Lock<InternerInner>);
2583+
// This is never de-initialized and stores interned &str in static storage.
2584+
// Each str is stored length-prefixed (u32), and we allow for random-access indexing with a u32
2585+
// index by direct lookup in the arena. Indices <2^16 are stored in a separate structure (they are
2586+
// pre-allocated at dense addresses so we can't use the same lockless O(1) hack for them).
2587+
static GLOBAL_ARENA: StringArena = StringArena::new();
25912588

2592-
// The `&'static str`s in this type actually point into the arena.
2593-
//
2594-
// This type is private to prevent accidentally constructing more than one
2595-
// `Interner` on the same thread, which makes it easy to mix up `Symbol`s
2596-
// between `Interner`s.
2597-
struct InternerInner {
2598-
arena: DroplessArena,
2599-
strings: FxIndexSet<&'static str>,
2589+
const CHUNK_SIZE: usize = 4 * 1024 * 1024;
2590+
const CHUNKS: usize = (u32::MAX as usize).div_ceil(CHUNK_SIZE);
2591+
2592+
struct StringChunk {
2593+
array: LazyLock<Box<[SyncUnsafeCell<u8>; CHUNK_SIZE]>>,
26002594
}
26012595

2602-
impl Interner {
2603-
fn prefill(init: &[&'static str]) -> Self {
2604-
Interner(Lock::new(InternerInner {
2605-
arena: Default::default(),
2606-
strings: init.iter().copied().collect(),
2607-
}))
2596+
impl StringChunk {
2597+
const fn new() -> Self {
2598+
Self {
2599+
array: LazyLock::new(|| unsafe {
2600+
// SAFETY: Zero-init'd UnsafeCell<u8> is initialized and has no other invariants to
2601+
// worry about.
2602+
Box::new_zeroed().assume_init()
2603+
}),
2604+
}
26082605
}
2606+
}
26092607

2610-
#[inline]
2611-
fn intern(&self, string: &str) -> Symbol {
2612-
let mut inner = self.0.lock();
2613-
if let Some(idx) = inner.strings.get_index_of(string) {
2614-
return Symbol::new(idx as u32);
2608+
struct StringArena {
2609+
chunks: [StringChunk; CHUNKS],
2610+
next_start: AtomicU32,
2611+
interned: elsa::sync::LockFreeFrozenVec<InternedString>,
2612+
}
2613+
2614+
#[derive(Copy, Clone)]
2615+
struct InternedString {
2616+
start: u32,
2617+
length: u32,
2618+
}
2619+
2620+
impl StringArena {
2621+
const fn new() -> Self {
2622+
StringArena {
2623+
chunks: [const { StringChunk::new() }; CHUNKS],
2624+
next_start: AtomicU32::new(0),
2625+
interned: elsa::sync::LockFreeFrozenVec::new(),
26152626
}
2627+
}
26162628

2617-
let string: &str = inner.arena.alloc_str(string);
2629+
fn next(previous: u32, length: u32) -> u32 {
2630+
let end = previous.checked_add(length).unwrap();
2631+
if previous / CHUNK_SIZE as u32 == end / CHUNK_SIZE as u32 {
2632+
end
2633+
} else {
2634+
// If we don't fit in the previous chunk, bump to the start of the next chunk, and set
2635+
// length to the end.
2636+
previous.next_multiple_of(CHUNK_SIZE as u32) + length
2637+
}
2638+
}
26182639

2619-
// SAFETY: we can extend the arena allocation to `'static` because we
2620-
// only access these while the arena is still alive.
2621-
let string: &'static str = unsafe { &*(string as *const str) };
2640+
/// Copy the passed &str into the arena. Returns an index that can be passed to `get` to
2641+
/// retrieve the &str.
2642+
///
2643+
/// u32 is guaranteed to be at least u16::MAX.
2644+
fn alloc(&self, s: &str) -> u32 {
2645+
let len = u32::try_from(s.len()).unwrap();
2646+
assert!(len < CHUNK_SIZE as u32);
2647+
2648+
let previous = self
2649+
.next_start
2650+
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |previous| {
2651+
Some(Self::next(previous, len))
2652+
})
2653+
.unwrap();
2654+
let end = Self::next(previous, len);
2655+
let start = end - len;
2656+
2657+
let chunk = LazyLock::force(&self.chunks[start as usize / CHUNK_SIZE].array);
2658+
let offset = start as usize % CHUNK_SIZE;
2659+
2660+
// SAFETY:
2661+
//
2662+
// * `next_start` only increases, and always uniquely allocates `len` bytes. No one can read
2663+
// this memory yet as we haven't pushed yet to `interned`.
2664+
// * all chunks are zero-init'd at allocation: no uninitialized memory here.
2665+
let dst = unsafe {
2666+
std::slice::from_raw_parts_mut(
2667+
chunk.as_ptr().cast::<u8>().add(offset).cast_mut(),
2668+
s.len(),
2669+
)
2670+
};
2671+
dst.copy_from_slice(s.as_bytes());
2672+
2673+
let idx = self.interned.push(InternedString { start, length: len });
2674+
2675+
idx.try_into().unwrap()
2676+
}
2677+
2678+
/// Get the allocated string at the passed index.
2679+
///
2680+
/// Note that this **does not** check that the passed index is actually an index returned by
2681+
/// `alloc`.
2682+
fn get(&self, idx: u32) -> &str {
2683+
let interned = self.interned.get(idx as usize).unwrap_or_else(|| {
2684+
panic!("non-interned symbol index: {idx}");
2685+
});
2686+
2687+
let Some(chunk) = LazyLock::get(&self.chunks[interned.start as usize / CHUNK_SIZE].array)
2688+
else {
2689+
// chunk must be initialized because `interned` points to the chunk.
2690+
unsafe { std::hint::unreachable_unchecked() }
2691+
};
2692+
let offset = interned.start as usize % CHUNK_SIZE;
2693+
2694+
// We write the string into this memory range prior to pushing into `interned`, so this is
2695+
// guaranteed UTF-8 and initialized. `next_start` is strictly increasing so we never write
2696+
// twice.
2697+
unsafe {
2698+
std::str::from_raw_parts(
2699+
chunk.as_ptr().add(offset).cast::<u8>(),
2700+
interned.length as usize,
2701+
)
2702+
}
2703+
}
2704+
}
26222705

2623-
// This second hash table lookup can be avoided by using `RawEntryMut`,
2624-
// but this code path isn't hot enough for it to be worth it. See
2625-
// #91445 for details.
2626-
let (idx, is_new) = inner.strings.insert_full(string);
2627-
debug_assert!(is_new); // due to the get_index_of check above
2706+
pub(crate) struct Interner(&'static [&'static str], Lock<InternerInner>);
26282707

2629-
Symbol::new(idx as u32)
2708+
struct InternerInner {
2709+
strings: hashbrown::HashTable<Symbol>,
2710+
}
2711+
2712+
impl Interner {
2713+
fn prefill(init: &'static [&'static str]) -> Self {
2714+
assert!(init.len() < u16::MAX as usize);
2715+
let mut strings = hashbrown::HashTable::with_capacity(32_000);
2716+
2717+
for (idx, s) in init.iter().copied().enumerate() {
2718+
let mut hasher = FxHasher::default();
2719+
s.hash(&mut hasher);
2720+
let hash = hasher.finish();
2721+
strings.insert_unique(hash, Symbol::new(idx as u32), |val| {
2722+
// has to be from `init` because we haven't yet inserted anything except those.
2723+
BuildHasherDefault::<FxHasher>::default().hash_one(init[val.0.index()])
2724+
});
2725+
}
2726+
2727+
Interner(init, Lock::new(InternerInner { strings }))
2728+
}
2729+
2730+
#[inline]
2731+
fn intern(&self, string: &str) -> Symbol {
2732+
let hash = BuildHasherDefault::<FxHasher>::default().hash_one(string);
2733+
let mut inner = self.1.lock();
2734+
match inner.strings.find_entry(hash, |v| self.get(*v) == string) {
2735+
Ok(e) => return *e.get(),
2736+
Err(e) => {
2737+
let idx = GLOBAL_ARENA.alloc(string);
2738+
// Reserve 2^16 u32 indices -- these will be used for pre-filled interning where we
2739+
// have a dense SymbolIndex space. We could make this exact but it doesn't really
2740+
// matter in practice, we won't run out of symbol space.
2741+
let idx = u32::from(u16::MAX).checked_add(idx).unwrap();
2742+
let res = Symbol::new(idx as u32);
2743+
2744+
e.into_table().insert_unique(hash, res, |val| {
2745+
BuildHasherDefault::<FxHasher>::default().hash_one(self.get(*val))
2746+
});
2747+
2748+
res
2749+
}
2750+
}
26302751
}
26312752

26322753
/// Get the symbol as a string.
26332754
///
26342755
/// [`Symbol::as_str()`] should be used in preference to this function.
2635-
fn get(&self, symbol: Symbol) -> &str {
2636-
self.0.lock().strings.get_index(symbol.0.as_usize()).unwrap()
2756+
fn get(&self, symbol: Symbol) -> &'static str {
2757+
if let Some(interned) = symbol.0.as_u32().checked_sub(u32::from(u16::MAX)) {
2758+
GLOBAL_ARENA.get(interned)
2759+
} else {
2760+
self.0[symbol.0.index()]
2761+
}
26372762
}
26382763
}
26392764

‎compiler/rustc_span/src/symbol/tests.rs

+7-5
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,17 @@ use crate::create_default_session_globals_then;
44
#[test]
55
fn interner_tests() {
66
let i = Interner::prefill(&[]);
7+
let dog = i.intern("dog");
78
// first one is zero:
8-
assert_eq!(i.intern("dog"), Symbol::new(0));
9+
assert_eq!(i.intern("dog"), dog);
910
// re-use gets the same entry:
10-
assert_eq!(i.intern("dog"), Symbol::new(0));
11+
assert_eq!(i.intern("dog"), dog);
1112
// different string gets a different #:
12-
assert_eq!(i.intern("cat"), Symbol::new(1));
13-
assert_eq!(i.intern("cat"), Symbol::new(1));
13+
let cat = i.intern("cat");
14+
assert_eq!(i.intern("cat"), cat);
15+
assert_eq!(i.intern("cat"), cat);
1416
// dog is still at zero
15-
assert_eq!(i.intern("dog"), Symbol::new(0));
17+
assert_eq!(i.intern("dog"), dog);
1618
}
1719

1820
#[test]

0 commit comments

Comments
 (0)
Failed to load comments.