Read OSS

The Arena Allocator and AST Design: Oxc's Performance Foundation

Advanced

Prerequisites

  • Rust ownership, borrowing, and lifetime annotations
  • Basic understanding of memory allocators
  • Article 1: Architecture and Crate Map

The Arena Allocator and AST Design: Oxc's Performance Foundation

Every performance-critical system has a foundational design decision that reverberates through every layer above it. For Oxc, that decision is arena allocation. Rather than scattering AST nodes across the heap with individual allocations — each requiring reference counting or garbage collection — Oxc allocates all nodes into a contiguous memory arena. This gives O(1) bulk deallocation, cache-friendly traversal, and zero reference-counting overhead. In this article, we'll trace the allocator's internals, then examine how the AST is designed to exploit arena allocation for maximum performance.

Why Arena Allocation?

In a traditional JavaScript tool written in Rust, you'd use Box<T> for heap-allocated nodes, and Rc<T> or Arc<T> for shared ownership. Every allocation goes through the global allocator (typically malloc), and every deallocation requires tracking individual lifetimes. For an AST with thousands of nodes, this creates two problems:

  1. Allocation overhead: Each malloc/free pair involves a system call or, at minimum, a trip through the allocator's free list.
  2. Cache pollution: Nodes allocated at different times end up scattered across memory, causing cache misses during traversal.

Arena allocation solves both. All AST nodes go into a bump allocator — a simple pointer that advances forward with each allocation. Deallocation is free: when you're done with the AST, drop the arena and all memory is released at once.

The Oxc architecture document states the trade-offs clearly:

  • ✅ 10–50% performance improvement
  • ✅ Simplified ownership model
  • ❌ Requires lifetime management
  • ❌ Less flexible memory patterns

In practice, the lifetime constraint ('a tied to the allocator) is manageable because compiler tools have a natural batch processing model: parse a file, process it, discard everything.

The Allocator Internals

The Allocator struct in crates/oxc_allocator/src/allocator.rs wraps an internal Bump allocator:

#[derive(Default)]
pub struct Allocator {
    bump: Bump,
}

The allocator grows through exponential chunking: each new chunk is at least double the size of the previous one. This means for typical files, the allocator stabilizes after just 2–3 rounds of reuse.

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

The alloc() Hot Path

The primary allocation method at allocator.rs#L300-L307 is aggressively inlined and includes a compile-time check that prevents Drop types from entering the 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)
}

This const assertion is elegant: it catches misuse at compile time, not runtime. You literally cannot allocate a std::vec::Vec or any other Drop type into the arena — the compiler refuses.

Recycling with reset()

For batch processing (like linting hundreds of files), Oxc reuses allocators via the reset() method. This keeps only the largest chunk (discarding smaller ones), rewinds the bump pointer, and avoids any system calls for re-allocation. The documentation at lines 83–160 explains in detail how capacity stabilizes after three rounds of identical workloads.

Tip: When processing multiple files in sequence, always reset the allocator between files rather than creating a new one. This avoids system calls and keeps the memory warm in CPU cache.

Arena Box and Vec

Oxc provides arena-aware replacements for std::boxed::Box and std::vec::Vec.

Box<'a, T>

The arena Box at crates/oxc_allocator/src/boxed.rs#L34-L35 is a simple NonNull<T> wrapper:

#[repr(transparent)]
pub struct Box<'alloc, T: ?Sized>(NonNull<T>, PhantomData<(&'alloc (), T)>);

Key differences from std::boxed::Box:

  • No Drop: Memory is freed when the allocator is dropped, not when the Box is dropped.
  • #[repr(transparent)]: The Box is exactly the same size as a raw pointer — zero overhead.
  • Compile-time Drop prevention: Box::new_in includes the same const assertion as alloc().

Vec<'a, T>

The arena Vec at crates/oxc_allocator/src/vec.rs#L39-L41 wraps an internal vector implementation:

#[repr(transparent)]
pub struct Vec<'alloc, T>(InnerVec<'alloc, T>);

One subtlety: Vec is Sync but not Send. Multiple threads can read a Vec simultaneously (since &Bump is shared), but you cannot move a Vec to another thread because that would allow concurrent allocations into a non-thread-safe Bump. This is enforced with a manual unsafe impl Sync at vec.rs#L56.

AST Design Philosophy: Beyond ESTree

Most JavaScript tooling uses the ESTree specification for AST node types. Oxc deliberately diverges from ESTree in ways that improve type safety, performance, and developer experience.

Differentiated Identifiers

ESTree has a single Identifier node. Oxc splits it into three types, each with different semantic meaning:

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"
    }

This split — visible in crates/oxc_ast/src/ast/js.rs — means that semantic analysis can store a SymbolId directly on BindingIdentifier and a ReferenceId on IdentifierReference. With ESTree, you'd need a separate lookup table to determine whether an Identifier is a binding or a reference. Oxc makes this distinction structural.

Typed Literals

Similarly, ESTree's generic Literal node becomes distinct types in Oxc. In crates/oxc_ast/src/ast/literal.rs:

  • BooleanLiteral — with a bool value
  • NullLiteral — no value field needed
  • NumericLiteral — with f64 value and NumberBase
  • StringLiteral — with a Str value
  • BigIntLiteral — with a string representation
  • RegExpLiteral — with pattern and flags

This eliminates the runtime type checking that ESTree-based tools constantly perform (e.g., if (node.type === 'Literal' && typeof node.value === 'number')).

The Program Root and Expression Enum

The root Program node at js.rs#L51-L67 shows the arena types in action:

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>>,
}

Every collection uses Vec<'a, T> (arena-allocated), every owned child uses Box<'a, T> (arena-allocated), and Cell is used for IDs that are filled in later by semantic analysis. The 'a lifetime ties everything to the allocator.

The Expression enum at js.rs#L78-L119 demonstrates another pattern — each variant wraps its payload in 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,
    // ...
}

The explicit discriminant values (= 0, = 1, etc.) are used by the code generation system for stable binary representations.

The Span Type: u32 for Compactness

Source positions use the Span type from crates/oxc_span/src/span.rs, which stores start and end as u32 byte offsets:

pub struct Span {
    pub start: u32,
    pub end: u32,
}

Using u32 instead of usize halves the size of Span from 16 bytes to 8 bytes on 64-bit systems. Since every AST node contains a Span, this saves significant memory across a typical AST with thousands of nodes. The trade-off is a 4GB file size limit — more than sufficient for any JavaScript file.

Arena-Aware Traits: CloneIn, TakeIn, and Dummy

Standard Rust Clone doesn't work with arena-allocated types — you can't clone a Box<'a, T> without specifying which allocator the clone lives in. Oxc solves this with three custom traits, all generated by 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 — Clone a node into a (potentially different) allocator. Used when you need to copy AST subtrees between allocators or duplicate nodes during transformation.
  • TakeIn — Replace a node with a dummy value, returning the original. This is the arena equivalent of mem::take() — essential when you need to move a node out of the AST without leaving a dangling reference.
  • Dummy — Create a zero-valued placeholder node. Every AST type has a Dummy implementation that produces a valid (but meaningless) instance.

These traits are derived automatically for all AST types via #[generate_derive(CloneIn, Dummy, TakeIn)] annotations. The ast_tools code generator inspects these annotations and emits the implementations. You'll see this code generation system in detail in Article 4.

Tip: When writing a transform that needs to move a child out of a parent node, use child.take_in(allocator) rather than trying to clone. It's O(1) — it just swaps the node with a dummy — while cloning traverses the entire subtree.

How the AST Types Are Annotated

Looking at Program's definition, you'll notice several attribute macros:

#[ast(visit)]
#[scope(flags = ScopeFlags::Top, strict_if = ...)]
#[generate_derive(CloneIn, Dummy, TakeIn, GetSpan, GetSpanMut, ContentEq, ESTree)]
pub struct Program<'a> { ... }

These attributes — #[ast], #[scope], #[visit], #[generate_derive] — are markers for the ast_tools code generator. They don't execute as proc macros at compile time. Instead, ast_tools reads these annotations when you run just ast and generates:

  • Visitor trait methods (visit_program, visit_expression, etc.)
  • Scope creation logic for SemanticBuilder
  • Trait implementations for CloneIn, GetSpan, ContentEq, etc.
  • Memory layout assertions

The #[ast] macro itself (in crates/oxc_ast_macros/src/lib.rs#L42-L47) adds #[repr(C)] to structs and #[repr(C, u8)] to enums, ensuring predictable memory layouts.

What's Next

The allocator and AST are the substrate on which everything else builds. In Article 3, we'll trace how source text becomes a fully-resolved AST — from the lexer's token stream through the hand-written recursive descent parser and into the SemanticBuilder, which constructs scope chains, symbol tables, and reference resolution in a single pass over the AST.