Always Bump Downwards: The Arena Allocator rustc Uses for Everything

rustc's arenas allocate downwards from the end of a chunk, tell LLVM about the alignment with assert_unchecked, and grow from 4 KiB to 2 MiB chunks.

TL;DR: rustc allocates almost everything — types, HIR, MIR, spans — from bump allocators. The trick is that the arena allocates downwards from the end of a chunk, and tells LLVM that the pointer is aligned with hint::assert_unchecked, which lets the compiler delete the alignment arithmetic entirely.

The arena

rust
// compiler/rustc_arena/src/lib.rs:344-362

/// An arena that can hold objects of multiple different types that impl `Copy`
/// and/or satisfy `!mem::needs_drop`.
pub struct DroplessArena {
    /// A pointer to the start of the free space.
    start: Cell<*mut u8>,

    /// A pointer to the end of free space.
    ///
    /// The allocation proceeds downwards from the end of the chunk towards the
    /// start. (This is slightly simpler and faster than allocating upwards,
    /// see <https://fitzgeraldnick.com/2019/11/01/always-bump-downwards.html>.)
    /// When this pointer crosses the start pointer, a new chunk is allocated.
    ///
    /// This is kept aligned to DROPLESS_ALIGNMENT.
    end: Cell<*mut u8>,

    /// A vector of arena chunks.
    chunks: RefCell<Vec<ArenaChunk>>,
}

The allocation proceeds downwards. The comment links to a blog post arguing why, but the short version is that downward allocation is marginally simpler: the alignment fix-up is a subtraction and a mask on a pointer that moves down, and the “have we run out?” check is a single unsigned comparison against the chunk’s start.

The allocation loop

rust
// compiler/rustc_arena/src/lib.rs:419-453

#[inline]
pub fn alloc_raw(&self, layout: Layout) -> *mut u8 {
    assert!(layout.size() != 0);

    // This loop executes once or twice: if allocation fails the first
    // time, the `grow` ensures it will succeed the second time.
    loop {
        let start = self.start.get().addr();
        let old_end = self.end.get();
        let end = old_end.addr();

        // Align allocated bytes so that `self.end` stays aligned to
        // DROPLESS_ALIGNMENT.
        let bytes = align_up(layout.size(), DROPLESS_ALIGNMENT);

        // Tell LLVM that `end` is aligned to DROPLESS_ALIGNMENT.
        unsafe { hint::assert_unchecked(end == align_down(end, DROPLESS_ALIGNMENT)) };

        if let Some(sub) = end.checked_sub(bytes) {
            let new_end = align_down(sub, layout.align());
            if start <= new_end {
                let new_end = old_end.with_addr(new_end);
                // `new_end` is aligned to DROPLESS_ALIGNMENT as `align_down`
                // preserves alignment as both `end` and `bytes` are already
                // aligned to DROPLESS_ALIGNMENT.
                self.end.set(new_end);
                return new_end;
            }
        }

        // No free space left. Allocate a new chunk to satisfy the request.
        // On failure the grow will panic or abort.
        self.grow(layout);
    }
}

The hint::assert_unchecked is the interesting line. It is a compile-time-only assertion that LLVM treats as an assume: given end % 8 == 0 and bytes % 8 == 0, LLVM knows end - bytes is already 8-aligned, so align_down(sub, layout.align()) reduces to a plain mask and — crucially — the whole “align the size up to 8” step collapses away.

Without the assume, LLVM must conservatively keep the full align_up / align_down arithmetic in the hot path.

The invariant really holds: grow aligns the chunk end down to DROPLESS_ALIGNMENT, and every allocation subtracts align_up(size, DROPLESS_ALIGNMENT) — a multiple of 8.

Chunk growth

rust
// compiler/rustc_arena/src/lib.rs:96-102

// The arenas start with PAGE-sized chunks, and then each new chunk is twice as
// big as its predecessor, up until we reach HUGE_PAGE-sized chunks, whereupon
// we stop growing. This scales well, from arenas that are barely used up to
// arenas that are used for 100s of MiBs. Note also that the chosen sizes match
// the usual sizes of pages and huge pages on Linux.
const PAGE: usize = 4096;
const HUGE_PAGE: usize = 2 * 1024 * 1024;

and:

rust
// compiler/rustc_arena/src/lib.rs:380-417

#[inline(never)]
#[cold]
fn grow(&self, layout: Layout) {
    // Add some padding so we can align `self.end` while
    // still fitting in a `layout` allocation.
    let additional = layout.size() + cmp::max(DROPLESS_ALIGNMENT, layout.align()) - 1;

    unsafe {
        let mut chunks = self.chunks.borrow_mut();
        let mut new_cap;
        if let Some(last_chunk) = chunks.last_mut() {
            // There is no need to update `last_chunk.entries` because that
            // field isn't used by `DroplessArena`.

            // If the previous chunk's len is less than HUGE_PAGE
            // bytes, then this chunk will be least double the previous
            // chunk's size.
            new_cap = last_chunk.storage.len().min(HUGE_PAGE / 2);
            new_cap *= 2;
        } else {
            new_cap = PAGE;
        }
        // Also ensure that this chunk can fit `additional`.
        new_cap = cmp::max(additional, new_cap);

        let chunk = chunks.push_mut(ArenaChunk::new(align_up(new_cap, PAGE)));
        self.start.set(chunk.start());

        // Align the end to DROPLESS_ALIGNMENT.
        let end = align_down(chunk.end().addr(), DROPLESS_ALIGNMENT);
        ...

Geometric growth: 4 KiB, 8 KiB, 16 KiB, … capped at 2 MiB, which is Linux’s HUGE_PAGE. The sizes deliberately match the OS’s page sizes so the allocator can hand back huge-page-aligned memory.

Note grow is #[cold] #[inline(never)] — the chunk allocation path never bloats the hot inline path.

Two arenas, one macro

rust
// compiler/rustc_arena/src/lib.rs:597-627

/// Declares an `Arena` that can allocate values of a variety of `Copy`, `needs_drop` and
/// `!needs_drop` types.
///
/// The declared arena actually contains a single [`DroplessArena`], plus a separate
/// [`TypedArena`] for each of the types listed in the body of the macro invocation.
///
/// Any type that is `Copy` can be allocated in the arena without needing to be listed
/// explicitly. Those values will be stored in the [`DroplessArena`].
///
/// Types that are `!Copy` can only be allocated if they are listed in the macro invocation.
/// For types that are `!Copy + needs_drop`, values will be stored in the corresponding
/// [`TypedArena`] and will be dropped when the arena is dropped.
///
/// As an optimization, types that are `!Copy + !needs_drop` will actually be stored in the
/// `DroplessArena`, and the corresponding [`TypedArena`] will remain empty. This makes
/// better use of the dropless arena's storage blocks, while the overhead of having a few
/// unused typed-arenas is negligible.
#[rustc_macro_transparency = "semiopaque"]
pub macro declare_arena!(
    // Each of these entries becomes a `$name: TypedArena<$ty>` field in the arena.
    // This allows values of non-copy type $ty to be allocated in the arena.
    // The field names must be distinct, but have no further significance.
    $(
        $name:ident: $ty:ty,
    )*
) {

A declarative macro (macro-rules 2.0) generates the arena type at the use site, so it is fully specialised and inlinable — no vtable, no dynamic dispatch.

And the routing:

rust
// compiler/rustc_arena/src/lib.rs:639-679

    // Any type that impls `Copy` can be arena-allocated in the `DroplessArena`.
    impl<'tcx, T: Copy> ArenaAllocitable<'tcx, rustc_arena::IsCopy> for T {
        #[inline]
        #[allow(clippy::mut_from_ref)]
        fn allocate_on(self, arena: &'tcx Arena<'tcx>) -> &'tcx mut Self {
            arena.dropless.alloc(self)
        }
        ...
    }
    $(
        impl<'tcx> ArenaAllocitable<'tcx, rustc_arena::IsNotCopy> for $ty {
            #[inline]
            fn allocate_on(self, arena: &'tcx Arena<'tcx>) -> &'tcx mut Self {
                if !::std::mem::needs_drop::<Self>() {
                    arena.dropless.alloc(self)
                } else {
                    arena.$name.alloc(self)
                }
            }

DroplessArena never runs destructors, so it is only valid for !needs_drop types. A droppable type goes to its own TypedArena<T>, which knows how many elements were initialised and drops them at teardown. But a !Copy + !needs_drop type — Option<NonZeroU32>, say — still goes into the dropless arena, because needs_drop is a compile-time constant that folds away. The comment says the trade explicitly: better use of the dropless storage, at the cost of a few unused typed arenas.

Why arenas at all?

Because rustc allocates enormous numbers of small, uniform-lifetime objects: every Ty, every HirId, every Span, every MIR statement. A general-purpose allocator’s per-object overhead (size class lookup, free list management, coalescing) is pure waste for objects that are all freed at once at the end of the compilation.

A bump allocator makes allocation two instructions and deallocation free.

Where it lives

  • compiler/rustc_arena/src/lib.rs — DroplessArena, TypedArena, declare_arena!.
  • compiler/rustc_middle/src/arena.rs — the compiler’s main arena.
  • compiler/rustc_hir/src/arena.rs — the HIR arena.

Takeaway

The assert_unchecked trick is the most transferable idea here: when you maintain an alignment invariant by construction, tell the compiler about it, and the alignment arithmetic disappears from the hot path. The downward bump is a smaller win but the same species of trick — choose the representation that makes the invariant easy to see and to state.

RustInternalsCompilerPerformanceMemoryAllocator