Arena 分配器与 AST 设计:Oxc 的性能基石
前置知识
- ›Rust 所有权、借用与生命周期标注
- ›内存分配器基础知识
- ›第 1 篇:架构与 Crate 全景图
Arena 分配器与 AST 设计:Oxc 的性能基石
每一个追求极致性能的系统,背后都有一个贯穿所有层次的核心设计决策。对 Oxc 而言,这个决策就是 arena 分配。与其将 AST 节点分散地堆放在堆内存中——每次分配都需要引用计数或垃圾回收——Oxc 选择将所有节点统一分配到一块连续的内存 arena 中。这带来了三个显著优势:O(1) 的批量释放、对缓存友好的遍历方式,以及零引用计数开销。本文将深入探讨分配器的内部实现,并分析 AST 的设计如何充分利用 arena 分配来榨取最大性能。
为什么选择 Arena 分配?
在用 Rust 编写的传统 JavaScript 工具中,通常使用 Box<T> 来分配堆上的节点,用 Rc<T> 或 Arc<T> 来处理共享所有权。每次分配都经过全局分配器(通常是 malloc),每次释放都需要追踪独立的生命周期。对于拥有成千上万个节点的 AST 来说,这会带来两个问题:
- 分配开销:每对
malloc/free调用都涉及系统调用,或至少需要遍历分配器的空闲链表。 - 缓存污染:在不同时刻分配的节点会散落在内存各处,导致遍历时频繁出现缓存未命中。
Arena 分配同时解决了这两个问题。所有 AST 节点都进入一个 bump 分配器——一个随每次分配向前推进的简单指针。释放几乎是免费的:当你用完 AST 后,直接丢弃 arena,所有内存一次性释放。
Oxc 架构文档对此做了清晰的权衡说明:
- ✅ 性能提升 10–50%
- ✅ 所有权模型大幅简化
- ❌ 需要管理生命周期
- ❌ 内存使用模式灵活性较低
在实践中,生命周期约束('a 绑定到分配器)是完全可以接受的,因为编译器工具天然契合批处理模型:解析一个文件,处理它,然后一次性丢弃所有内容。
分配器内部实现
crates/oxc_allocator/src/allocator.rs 中的 Allocator 结构体封装了内部的 Bump 分配器:
#[derive(Default)]
pub struct Allocator {
bump: Bump,
}
分配器通过指数级分块增长:每个新块的大小至少是前一块的两倍。这意味着对于典型文件而言,经过 2–3 轮复用后,分配器的内存占用就会趋于稳定。
flowchart LR
subgraph Allocator Chunks
C1[Chunk 1: 4KB] --> C2[Chunk 2: 8KB] --> C3[Chunk 3: 16KB]
end
ptr[Bump Pointer] -.-> C3
style C1 fill:#f9f,stroke:#333
style C2 fill:#f9f,stroke:#333
style C3 fill:#bbf,stroke:#333
alloc() 热路径
allocator.rs#L300-L307 中的核心分配方法被强制内联,并包含一个编译期检查,用于防止带有 Drop 语义的类型进入 arena:
#[inline(always)]
pub fn alloc<T>(&self, val: T) -> &mut T {
const { assert!(!std::mem::needs_drop::<T>(), "Cannot allocate Drop type in arena") };
self.bump.alloc(val)
}
这个 const 断言设计得很精妙:它在编译期而非运行时捕获误用。你根本无法将 std::vec::Vec 或任何其他 Drop 类型分配进 arena——编译器会直接拒绝。
通过 reset() 复用内存
对于批量处理场景(比如对数百个文件执行 lint),Oxc 通过 reset() 方法复用分配器。该方法只保留最大的内存块(丢弃较小的块),将 bump 指针回退到起点,整个过程无需任何系统调用。文档的第 83–160 行详细说明了在处理相同规模工作负载的三轮之后,内存容量如何趋于稳定。
提示: 批量处理多个文件时,在文件之间调用
reset()复用分配器,而不是每次都创建新的。这样不仅避免了系统调用,还能让内存保持在 CPU 缓存中保持热状态。
Arena Box 与 Vec
Oxc 提供了针对 arena 的 std::boxed::Box 和 std::vec::Vec 替代类型。
Box<'a, T>
crates/oxc_allocator/src/boxed.rs#L34-L35 中的 arena Box 是一个简单的 NonNull<T> 包装器:
#[repr(transparent)]
pub struct Box<'alloc, T: ?Sized>(NonNull<T>, PhantomData<(&'alloc (), T)>);
与 std::boxed::Box 的关键区别:
- 无 Drop:内存在分配器被丢弃时释放,而非在
Box被丢弃时释放。 #[repr(transparent)]:Box的大小与裸指针完全相同——零额外开销。- 编译期 Drop 防护:
Box::new_in包含与alloc()相同的const断言。
Vec<'a, T>
crates/oxc_allocator/src/vec.rs#L39-L41 中的 arena Vec 封装了内部的向量实现:
#[repr(transparent)]
pub struct Vec<'alloc, T>(InnerVec<'alloc, T>);
有一个细节值得注意:Vec 实现了 Sync,但没有实现 Send。多个线程可以同时读取一个 Vec(因为 &Bump 是共享的),但你不能将 Vec 移动到另一个线程,因为那会导致多个线程并发写入非线程安全的 Bump。这一约束通过 vec.rs#L56 中的手动 unsafe impl Sync 来强制执行。
AST 设计哲学:超越 ESTree
大多数 JavaScript 工具使用 ESTree 规范定义 AST 节点类型。Oxc 在多个方面有意偏离 ESTree,以换取更好的类型安全性、更高的性能和更友好的开发体验。
细分的标识符类型
ESTree 只有一个 Identifier 节点。Oxc 将其拆分为三种类型,每种都有明确的语义含义:
classDiagram
class BindingIdentifier {
+Span span
+Ident name
+Cell~Option~SymbolId~~ symbol_id
"Variable declarations"
}
class IdentifierReference {
+Span span
+Ident name
+Cell~Option~ReferenceId~~ reference_id
"Variable references"
}
class IdentifierName {
+Span span
+Ident name
"Property names, labels"
}
这一拆分——可在 crates/oxc_ast/src/ast/js.rs 中看到——意味着语义分析可以直接将 SymbolId 存储在 BindingIdentifier 上,将 ReferenceId 存储在 IdentifierReference 上。如果使用 ESTree,你还需要额外的查找表来判断一个 Identifier 究竟是绑定还是引用。Oxc 将这种区分提升到了类型结构层面。
类型化的字面量
同样地,ESTree 中通用的 Literal 节点在 Oxc 中变成了独立的类型。在 crates/oxc_ast/src/ast/literal.rs 中:
BooleanLiteral— 包含bool值NullLiteral— 不需要值字段NumericLiteral— 包含f64值和NumberBaseStringLiteral— 包含Str值BigIntLiteral— 以字符串形式表示RegExpLiteral— 包含正则模式和标志
这彻底消除了 ESTree 工具中无处不在的运行时类型检查(例如 if (node.type === 'Literal' && typeof node.value === 'number'))。
Program 根节点与 Expression 枚举
js.rs#L51-L67 中的根节点 Program 展示了 arena 类型的实际应用:
pub struct Program<'a> {
pub node_id: Cell<NodeId>,
pub span: Span,
pub source_type: SourceType,
pub source_text: &'a str,
pub comments: Vec<'a, Comment>,
pub hashbang: Option<Hashbang<'a>>,
pub directives: Vec<'a, Directive<'a>>,
pub body: Vec<'a, Statement<'a>>,
pub scope_id: Cell<Option<ScopeId>>,
}
所有集合使用 Vec<'a, T>(arena 分配),所有拥有所有权的子节点使用 Box<'a, T>(arena 分配),Cell 用于存放由语义分析后续填充的 ID。'a 生命周期将一切都绑定到分配器上。
js.rs#L78-L119 中的 Expression 枚举展示了另一种模式——每个变体都将其内容包裹在 Box<'a, T> 中:
pub enum Expression<'a> {
BooleanLiteral(Box<'a, BooleanLiteral>) = 0,
NullLiteral(Box<'a, NullLiteral>) = 1,
NumericLiteral(Box<'a, NumericLiteral<'a>>) = 2,
// ...
Identifier(Box<'a, IdentifierReference<'a>>) = 7,
// ...
}
显式的判别值(= 0、= 1 等)由代码生成系统用于生成稳定的二进制表示。
Span 类型:用 u32 节省空间
源码位置信息使用 crates/oxc_span/src/span.rs 中的 Span 类型,以 u32 字节偏移量存储起止位置:
pub struct Span {
pub start: u32,
pub end: u32,
}
用 u32 代替 usize,在 64 位系统上将 Span 的大小从 16 字节压缩到 8 字节。由于每个 AST 节点都包含一个 Span,在拥有数千个节点的典型 AST 中,这能节省可观的内存。代价是最大支持 4GB 的文件——对任何 JavaScript 文件来说都绰绰有余。
Arena 感知的 Trait:CloneIn、TakeIn 与 Dummy
标准 Rust 的 Clone 无法直接用于 arena 分配的类型——克隆一个 Box<'a, T> 时,必须指明克隆对象所在的分配器。Oxc 为此提供了三个自定义 trait,均由 ast_tools 自动生成:
flowchart TD
CI[CloneIn] -->|"clone into allocator"| NewNode[New arena node]
TI[TakeIn] -->|"replace with dummy"| OldNode[Original node becomes Dummy]
DU[Dummy] -->|"create placeholder"| Placeholder[Zero-valued node]
CloneIn— 将节点克隆到(可能是另一个)分配器中。适用于在转换过程中跨分配器复制 AST 子树或复制节点。TakeIn— 用 dummy 值替换节点,并返回原始节点。这是 arena 版本的mem::take()——当你需要将节点从 AST 中移出,同时又不留下悬空引用时,它必不可少。Dummy— 创建一个零值占位节点。每种 AST 类型都有Dummy实现,能生成一个合法(但无意义)的实例。
这些 trait 通过 #[generate_derive(CloneIn, Dummy, TakeIn)] 标注为所有 AST 类型自动派生。ast_tools 代码生成器读取这些标注并生成对应的实现。我们将在第 4 篇文章中详细介绍这套代码生成系统。
提示: 在编写需要将子节点从父节点中移出的转换逻辑时,使用
child.take_in(allocator)而非克隆。它是 O(1) 的——只是将节点与 dummy 互换——而克隆则需要遍历整个子树。
AST 类型的标注方式
观察 Program 的定义,你会注意到几个属性宏:
#[ast(visit)]
#[scope(flags = ScopeFlags::Top, strict_if = ...)]
#[generate_derive(CloneIn, Dummy, TakeIn, GetSpan, GetSpanMut, ContentEq, ESTree)]
pub struct Program<'a> { ... }
#[ast]、#[scope]、#[visit]、#[generate_derive] 这些属性是供 ast_tools 代码生成器读取的标记,它们在编译时并不作为 proc macro 执行。当你运行 just ast 时,ast_tools 会读取这些标注并生成以下内容:
- Visitor trait 方法(
visit_program、visit_expression等) SemanticBuilder的作用域创建逻辑CloneIn、GetSpan、ContentEq等 trait 的实现- 内存布局断言
#[ast] 宏本身(位于 crates/oxc_ast_macros/src/lib.rs#L42-L47)会为结构体添加 #[repr(C)],为枚举添加 #[repr(C, u8)],从而确保可预测的内存布局。
下一篇预告
分配器和 AST 是 Oxc 一切功能的底层基础。在第 3 篇文章中,我们将完整追踪源码文本转变为全量解析 AST 的全过程——从词法分析器的 token 流,到手写的递归下降解析器,再到 SemanticBuilder,后者在对 AST 进行单次遍历的过程中同时完成作用域链构建、符号表创建和引用解析。