Rust 性能优化:字符串驻留(Symbol)和SmartString 的实现解析

204 阅读4分钟

字符串驻留 —— String Interning(Symbol)

在文件解析等任务中,经常会遇到高频的字符串比对操作,例如编译器的词法分析器(Lexer),以及像 ProtoBufferText 这类文件的解析。前者存在大量的关键词,而 DSL文件中也有许多重复的字段名

在这些场景中,如果直接使用 String 类型,因为数以万计的字符串克隆操作会让性能急剧下降。&str 类型倒是不错,文件流的生命周期足够长,引用访问速度也很快,不过字符串的显著缺陷是,它的比对需要逐字节比对,例如解析Token,假如有100种Token,这个O(n)的比对最坏的情况下要执行100次

enum Token {
    Fn,
    Let,
    Match,
    Ident,
    // ..
}

fn classify_token(token: &str) -> Token {
    match token {
        "fn" => Token::Fn,
        "let" => Token::Let,
        "match" => Token::Match,
        // ... many other tokens
    }
}

在这种场景下,使用更多的是 Symbol 类型。以下都代码均为Rustc源代码中使用的Symbol,其位于compiler/rustc_span目录下

Symbol是一个线程局部(thread_local)变量,其数据类型为 Map<&'static str, u32>

以下是 Rust 编译器(rustc)所使用的 Symbol 数据类型的定义:

pub struct Symbol(SymbolIndex);

其中的SymbolIndex是一个u32的自增id

下面是 Symbol 的主要方法:

impl Symbol {
    pub const fn new(n: u32) -> Self {
        Symbol(SymbolIndex::from_u32(n))
    }

    pub fn intern(string: &str) -> Self {
        with_session_globals(|session_globals| 
            session_globals.symbol_interner.intern(string
        ))
    }

    pub fn as_str(&self) -> &str {
        with_session_globals(|session_globals| unsafe {
            std::mem::transmute::<&str, &str>(
                session_globals.symbol_interner.get(*self)
            )
        })
    }
}

这里的 with_session_globals 函数实际上就是 SESSION_GLOBALS.with,其类型和定义如下代码,SESSION_GLOBALS使用的是scoped_thread_local,避免资源泄露

pub struct SessionGlobals {
    symbol_interner: symbol::Interner,
    span_interner: Lock<span_encoding::SpanInterner>,
    metavar_spans: MetavarSpansMap,
    hygiene_data: Lock<hygiene::HygieneData>,
    source_map: Option<Arc<SourceMap>>,
}

pub fn with_session_globals<R, F>(f: F) -> R
where
    F: FnOnce(&SessionGlobals) -> R,
{
    SESSION_GLOBALS.with(f)
}

scoped_tls::scoped_thread_local!(static SESSION_GLOBALS: SessionGlobals);

其中 Interner 结构体的定义如下:

pub(crate) struct Interner(Lock<InternerInner>);

struct InternerInner {
    arena: DroplessArena,
    strings: FxIndexSet<&'static str>,
}

Rust 编译器的 Interner 使用的是 DroplessArena

pub struct DroplessArena {
    start: Cell<*mut u8>,
    end: Cell<*mut u8>,
    chunks: RefCell<Vec<ArenaChunk>>,
}

DroplessArena 的内存分配只需移动指针,就像寄存器 rbprsp 的操作一样。而且正如其名称所示,其中存储的数据不会被释放,生命周期为 'static,不会产生内存碎片,内存分配速度显著快于系统内存分配。strings 是一个 indexmap::IndexSetindexmap 可以保持元素的插入顺序,Symbol 直接使用这个插入顺序作为 idhash算法是目前Rust中最快的 FxHasher

下面是 intern 方法的具体实现:

impl Interner {
    #[inline]
    fn intern(&self, string: &str) -> Symbol {
        let mut inner = self.0.lock();
        // 绝大多数情况下在这一行返回
        if let Some(idx) = inner.strings.get_index_of(string) {
            return Symbol::new(idx as u32);
        }

        let string: &str = inner.arena.alloc_str(string);

        let string: &'static str = unsafe { &*(string as *const str) };

        let (idx, is_new) = inner.strings.insert_full(string);
        debug_assert!(is_new); 

        Symbol::new(idx as u32)
    }
}

在文件解析场景中,例如以下代码:

fn classify_token_with_symbol(token: &str) -> Token {
    match SYMBOL.intern(token) {
        sym::fn_ => Token::Fn,
        sym::let_ => Token::Let,
        sym::match_ => Token::Match,
        // ... many other tokens
    }
}

首次遇到字符串,需要计算哈希值、处理哈希冲突(几乎不可能遇到)并插入值;后续遇到相同字符串时,只需计算哈希值,然后返回 Symbol 内部的 u32,每个分支的匹配只需比对这个 u32 值即可

由于关键字或者字段名通常是我们已知的,所以可以在最开始Intern所有这些字符串

SmartString —— 栈存储小字符串

在 64 位平台上,标准的 String 类型 24 字节的空间,其中指针(ptr)、长度(len)和容量(cap)各占 8 字节,它们的数据类型均为 usize

当字符串长度不超过 23 字节时,SmartString 会将字符串的 u8 数组直接存储在栈上。此时,剩余的 1 字节(共 8 位)被巧妙利用。其中 1 位作为标志位,用于区分字符串是存储在栈上(InlineString)还是堆上(BoxedString);剩下的 7 位则用于存储字符串的长度信息。由于栈上字符串的最大长度为 23,而 2 ^ 7 = 128,足以存储长度信息

BoxedStringlayout就是ptrlencap这三个usize类型,它没有标志位,那么如何判断呢?

内存分配器会对指针进行对齐,一般是按照 2 的幂次方(如 2、4、8 字节等)对齐,这就保证了指针的最低位为 0,那么只要InlineString的标志位设置为1,根据标志位的奇偶来判断就好了

在处理小字符串的场景中,clone 操作转化为了栈上的复制(Copy)操作,不仅大幅提升性能,还减轻了开发者在处理 clone 操作时的心智负担

但需要注意的是,即便只是 24 字节的栈上复制操作,其性能也低于&str

基于类似的思路,还有smallvec这个著名的crate,从2.0版本,它的实现使用了Const Generic特性,有机会再说