Read OSS

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 来说,这会带来两个问题:

  1. 分配开销:每对 malloc/free 调用都涉及系统调用,或至少需要遍历分配器的空闲链表。
  2. 缓存污染:在不同时刻分配的节点会散落在内存各处,导致遍历时频繁出现缓存未命中。

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::Boxstd::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 值和 NumberBase
  • StringLiteral — 包含 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_programvisit_expression 等)
  • SemanticBuilder 的作用域创建逻辑
  • CloneInGetSpanContentEq 等 trait 的实现
  • 内存布局断言

#[ast] 宏本身(位于 crates/oxc_ast_macros/src/lib.rs#L42-L47)会为结构体添加 #[repr(C)],为枚举添加 #[repr(C, u8)],从而确保可预测的内存布局。

下一篇预告

分配器和 AST 是 Oxc 一切功能的底层基础。在第 3 篇文章中,我们将完整追踪源码文本转变为全量解析 AST 的全过程——从词法分析器的 token 流,到手写的递归下降解析器,再到 SemanticBuilder,后者在对 AST 进行单次遍历的过程中同时完成作用域链构建、符号表创建和引用解析。