Skip to content

perf: gc_malloc TLS overhead limits per-allocation speed (6x Node on strings, 3.8x on objects) #62

Description

@proggeramlug

Summary

After the fixes in #58, #59, #60, the remaining performance gap for allocation-heavy workloads comes from per-allocation overhead in gc_malloc. Each allocation performs 6 thread-local storage (TLS) accesses plus 2 RefCell::borrow_mut() panic checks, a Vec::push, and a HashSet::insert.

Per-string-allocation cost: Perry ~140ns vs Node/V8 ~22ns (V8 bump-allocates young-gen strings in ~1-2ns).

Benchmarks

// Pattern 1: pure numeric object allocation
const N = 500000;
const start = Date.now();
let sum = 0;
for (let i = 0; i < N; i++) {
  const obj: any = { x: i, y: i * 2, z: i * 3 };
  sum += obj.x;
}
console.log("obj_only:", Date.now() - start, "ms");
// Pattern 2: string + number concat (most common real-world case)
for (let i = 0; i < 500000; i++) {
  const s = "item_" + i;
}
Pattern Perry Node Ratio
Pure object alloc ({x,y,z}) 15ms 4ms 3.8x slower
3-element numeric array 13ms 4ms 4.3x slower
"item_" + i 71ms 11ms 6.5x slower
i.toString() 85ms 9ms 9.4x slower
Template `item_${i}` 68ms 8ms 8.5x slower
Combined (gc_pressure bench) 185ms 12ms 15.4x slower

Root Cause: TLS-Heavy Allocation Path (gc.rs)

Every call to gc_malloc executes this sequence:

pub fn gc_malloc(size: usize, obj_type: u8) -> *mut u8 {
    gc_check_trigger();                              // TLS read: GC_IN_ALLOC, GC_SUPPRESSED,
                                                     //           GC_NEXT_TRIGGER_BYTES
    // ... alloc + header init ...
    GC_IN_ALLOC.with(|f| f.set(true));               // TLS write
    MALLOC_OBJECTS.with(|list| {
        list.borrow_mut().push(header);              // TLS + RefCell + Vec::push
    });
    MALLOC_SET.with(|set| {
        set.borrow_mut().insert(header as usize);    // TLS + RefCell + HashSet::insert
    });
    GC_IN_ALLOC.with(|f| f.set(false));              // TLS write
    user_ptr
}

Per allocation cost breakdown:

Operation Cost
System malloc + GcHeader init ~15-25ns
5-6× TLS lookups @ ~30-40ns each ~180-240ns
RefCell::borrow_mut() panic checks ~5-10ns
Vec::push (capacity check) ~3-5ns
HashSet::insert (hash + table write) ~10-15ns
Total ~210-315ns

V8 by comparison: single pointer increment in a bump allocator (~1-2ns) with no per-object tracking for young generation.

Why This Matters

Suggested Fix Approaches (ranked by effort)

1. Feature-gate MALLOC_SET in release builds (easy, ~40ns saved)

MALLOC_SET is only used to validate gc_realloc can find the object. In release builds, skip the HashSet — gc_realloc failures can fall back to copy+free. Gate behind #[cfg(debug_assertions)] or a cargo feature.

2. Bundle TLS state into one struct, one access per call (medium, ~100ns saved)

Replace 5-6 separate TLS.with() calls with a single state struct:

struct MallocState {
    in_alloc: bool,
    objects: Vec<*mut GcHeader>,
    next_trigger: usize,
    // ...
}
thread_local! { static STATE: RefCell<MallocState> = ... }

// Hot path becomes:
STATE.with(|s| {
    let mut state = s.borrow_mut();
    if !state.in_alloc && !state.suppressed { ... check trigger ... }
    state.in_alloc = true;
    state.objects.push(header);
    state.in_alloc = false;
});

One TLS lookup, one borrow_mut, all state access via struct fields (register-allocated).

3. Inline small-alloc fast path (medium, ~150ns saved for allocs <64 bytes)

For small, short-lived allocations (strings, small objects), bump-allocate into a per-thread 64KB arena block. Track the block pointer once per block, not once per object. Free the whole block during GC.

4. Codegen uses gc_malloc_batch for hot loops (medium)

gc_malloc_batch already exists (gc.rs:242) but codegen never emits it. For a loop allocating N objects per iteration, the compiler could recognize the pattern and emit a single batch call — amortizes TLS/Vec overhead across all N.

5. Generational GC (hard, matches V8 semantics)

  • Young generation: bump allocator, no per-object tracking, collected frequently via copying GC. ~1-2ns/alloc.
  • Old generation: current mark-sweep with tracking.
  • New allocs go in young gen; survivors promoted on survival.

This is the architecturally "correct" fix but is weeks of work. Approaches 1+2+3 combined should get within 2-3x of Node for typical workloads, which is probably sufficient for now.

Related Issues

The common thread across all four is that gc_malloc is on the hot path, and optimizations to individual features eventually bottom out against the allocation overhead.

Version

v0.5.65

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    performanceRuntime, compile-time, build-size, or memory performance

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions