Skip to content

perf(runtime): allocation path spends 34% of self time in _tlv_get_addr — 24× behind Node on object churn with the collector already idle #7469

Description

@proggeramlug

Summary

With the GC collection problems closed (#7377 nursery cap + scavenge, #7432 adaptive
tenuring, #7443 old-gen hole free list, #7449 recycled-block pool, #6893 per-class
layout interning), allocation-heavy programs are no longer limited by the collector —
they are limited by the mutator's allocation path. On churn.ts, total GC pause is
now 0.03 s out of a 4.33 s run (0.7%), yet Perry is still 24× slower than Node on
the same program.

A sample(1) profile of that run puts 34.2% of all self time in _tlv_get_addr
macOS's thread-local-variable accessor. The allocation and write-barrier paths resolve
thread-local storage over and over per allocation, and that resolution now costs more
than the allocation work itself.

This ticket covers closing that gap. Two workstreams, A being much the larger.

Where things stand (measured 2026-08-05, 351742d30, quiet 10-core M-series, load 2–6)

bench Perry scriptc node Perry RSS scriptc RSS node RSS
churn — 20M short-lived 2-field objects, ~1000 live 4.33 s 0.80 s 0.18 s 24 MB 2 MB 86 MB
retain — 3M live records 4.65 s 0.11 s 0.15 s 423 MB 118 MB 364 MB
tree — 21M nodes built/dropped, ~524k live 10.42 s 7.52 s 0.44 s 193 MB 74 MB 313 MB
cycles — 2M dead reference cycles 1.24 s 0.29 s 0.07 s 29 MB 2 MB 84 MB
deeplist — 1M-node linked list 1.80 s 124.5 s 0.10 s 163 MB 148 MB 145 MB

Best-of-3 wall, /usr/bin/time -l peak RSS. Programs in gc-handoff/bench/.

GC work per program is now small — PERRY_GC_TRACE=1, summing copying_nursery:

bench cycles copied object-copies total pause max pause
churn 105 0.004 GB 0.0 M 0.03 s 2.3 ms
tree 43 0.017 GB 0.2 M 6.79 s 255.6 ms
retain 11 0.018 GB 0.2 M 4.12 s 786.6 ms

tree copying is down 258× from the pre-#7432 baseline (4.39 GB / 61.0 M
object-copies / 1427 cycles). The collector is doing its job; the remaining time is
in the mutator.

Workstream A — thread-local access on the allocation hot path (the 34%)

Evidence

sample over a 3 s window of ./n_churn (2015 leaf samples):

  689   34.2%  _tlv_get_addr  (in libdyld.dylib)
   94    4.7%  <n_churn +0x5c4588>
   60    3.0%  <n_churn +0x109574>
   48    2.4%  <n_churn +0x32dda8>
   38    1.9%  <n_churn +0x1094b8>
   29    1.4%  _platform_memset_pattern16

_tlv_get_addr appears at 41 distinct call-graph sites — this is diffuse, not one
hot caller. It is the accessor macOS emits for every #[thread_local] / thread_local!
access; unlike ELF's initial-exec/local-exec models it is a real out-of-line call, and
it is not inlined or cached across accesses.

The runtime declares 237 thread_local! blocks. Access counts in the three files
on the object-allocation path:

file .with( call sites
crates/perry-runtime/src/gc/layout.rs 39
crates/perry-runtime/src/gc/barrier.rs 31
crates/perry-runtime/src/arena/allocators.rs 15

A single js_object_allocarena_alloc_gc → store-fields sequence touches several of
these independently: ARENA_FREE_LIST_NONEMPTY, ARENA_FREE_LIST, the arena itself, the
GC trigger state, the write barrier's remembered-set state, and the layout side table —
each its own _tlv_get_addr round trip. arena_alloc_gc at
crates/perry-runtime/src/arena/allocators.rs:266 already shows the pattern: the
free-list Cell check was optimised down to "~1ns" per the comment, but it is still a
separate TLS resolution.

Note this cost is invisible to PERRY_GC_TRACE accounting — it is mutator time, not
pause time, which is why it survived the whole GC campaign unnoticed.

Task

Cut the number of TLS resolutions per allocation, ideally to one per runtime entry.
Approaches, in rough order of expected value — measure before committing to one:

  1. Fetch a runtime-context pointer once per entry point and thread it down. Group the
    hot per-thread state (arena cursor + limit, free-list flag, trigger counters, barrier
    state) into one struct, resolve its address once at the js_* FFI boundary, and pass
    &mut Ctx to the internal helpers. This turns N resolutions per allocation into one,
    and is the only approach that composes with inlining.
  2. Cache the resolved base within a single runtime call. Cheaper to land than 1 and
    captures much of it where a call does several accesses back-to-back — but does nothing
    across calls.
  3. TLS model. For a statically linked executable, local-exec avoids the accessor
    entirely. Check whether -Z tls-model=local-exec is usable for the runtime staticlib
    on Darwin and what it costs in toolchain terms (nightly-only today). Treat as a
    measurement to inform 1, not a shipping plan.
  4. Inline the bump-allocate fast path into generated code. The endgame: codegen emits
    the cursor bump directly against a register-cached arena pointer, calling into the
    runtime only on block-exhaustion. Largest win, largest scope — likely its own ticket
    once 1 lands. Relates to the representation-selection work (RFC: Stabilize a Perry native value profile for fixed-width scalars, POD records, and native boundaries #6827, Typed Native Specialization Pipeline: explicit native reps and verifiable native regions #1849).

Symbolicate the anonymous frames before choosing: the four <n_churn +0x…> entries above
are 12% combined and worth naming. Build without strip, or resolve against
target/release/libperry_runtime.a.

Workstream B — residual per-object footprint

Marginal cost of one {a: number, b: number} record, measured as (3M-live RSS − 1M-live
RSS) / 2M so baseline noise cancels:

runtime bytes/record vs Perry
Perry 133 B
node 72 B 1.85× better
scriptc 40 B 3.3× better

This is already down from 289 B before #6893 interned the layout masks per class —
that change alone bought 2.2×. What remains:

  • 72 B object floor. 8 B GcHeader + 32 B ObjectHeader + INLINE_SLOT_FLOOR slots.
    INLINE_SLOT_FLOOR = 4 (crates/perry-runtime/src/object/mod.rs:24) and every
    allocator does max(field_count, INLINE_SLOT_FLOOR), so a 2-field object pays for 4 —
    16 B wasted. Right-sizing allocation sites with a compile-time-known shape (object
    literals with a fixed key set, class instances with a fixed field count) takes the floor
    to 56 B. Keep the floor for the genuinely-unknown-shape path: it exists because
    js_object_set_field_by_name writes up to alloc_limit slots inline, and shrinking it
    blindly is a heap-buffer-overflow into adjacent arena objects.
  • 32 B ObjectHeader (object/mod.rs:1460): object_type, class_id,
    parent_class_id, field_count (4× u32) plus keys_array and meta pointers. V8
    carries one map pointer. parent_class_id is derivable from the class registry, and the
    Architecture: adopt V8's object-model construction — explicit runtime state, self-describing headers, shape tree (phases A–C) #6759 Shape work may already subsume keys_array — worth checking what can be folded.
  • ~53 B/record is unaccounted by object + array slot (80 B) and is GC headroom, survivor
    copies and array-growth garbage. Quantify it before optimising it.

ObjectMeta is not a contributor — it is lazily allocated and null at construction
(object/alloc.rs:147 and siblings).

Repro

cd gc-handoff/bench
export PERRY_RUNTIME_DIR=<repo>/target/release
for f in churn retain retain1 tree cycles deeplist; do
  PERRY_NO_AUTO_OPTIMIZE=1 <repo>/target/release/perry $f.ts -o n_$f
done

# A — the profile
./n_churn & P=$!; sleep 1; sample $P 3 -mayDie -f /tmp/churn.sample; wait $P
# read the "Sort by top of stack" section

# GC share of the run (expect ~0.7% on churn — the collector is not the problem)
PERRY_GC_TRACE=1 ./n_churn 2>&1 | grep collection_kind | python3 -c "
import sys,json
r=[(json.loads(l).get('pause_us') or 0)/1000 for l in sys.stdin]
print(f'cycles={len(r)} totalPause={sum(r)/1000:.2f}s maxPause={max(r):.1f}ms')"

# B — marginal bytes per record
/usr/bin/time -l ./n_retain   # 3M live
/usr/bin/time -l ./n_retain1  # 1M live
# marginal = (RSS_3M - RSS_1M) / 2e6

Acceptance criteria

  1. _tlv_get_addr drops below 5% of self time on the churn.ts profile (from 34.2%).
  2. churn.ts wall time improves by ≥2× (4.33 s → ≤2.2 s). Stretch: within 5× of Node
    (≤0.9 s).
  3. tree.ts and retain.ts improve measurably and neither regresses on peak RSS.
  4. No GC regressions. Re-run the per-cycle trace: churn stays ≥100 cycles with
    positive reclamation every cycle and max pause in the low milliseconds; tree copying
    volume stays at the post-gc: adaptive tenuring + young-scoped scavenge cap (fixes the large-live-set scavenge regression) #7432 level (~0.017 GB / ~0.2 M object-copies, i.e. does not
    drift back toward 4.39 GB / 61 M).
  5. The 8 gc_ratchet probes plus 12_large_live_set (added by gc: adaptive tenuring + young-scoped scavenge cap (fixes the large-live-set scavenge regression) #7432) hold their results.
  6. Workstream B: marginal bytes/record drops from 133 B to ≤100 B, target 80 B. Add a
    test that an unknown-shape {} still takes 8 inline property writes without corrupting
    adjacent arena objects.
  7. Full cargo test workspace sweep green (exclude cross-host UI crates on macOS).

Traps

  • Never benchmark while a build runs. This machine routinely sits at load 20–140 from
    other agents; check uptime first. Prefer user-CPU time and best-of-N; peak RSS and GC
    traces are the load-independent signals.
  • Rebuild runtime and stdlib — perry-runtime is rlib-only, the .a comes from the
    -static wrappers:
    cargo build --release -p perry -p perry-runtime-static -p perry-stdlib-static.
  • Use PERRY_NO_AUTO_OPTIMIZE=1 on ad-hoc compiles, and rm -rf node_modules/.cache/perry
    after switching compilers or you will link stale objects.
  • Do not use CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 for anything you intend to trust —
    it miscompiles the release runtime.
  • macOS RSS overstates retention (mimalloc MADV_FREE pages stay resident); the heap shows
    up as Memory Tag 240, not MALLOC_*. See docs/src/internals/memory-model.md.
  • total pause from a PERRY_GC_TRACE=1 run can exceed the wall time of an untraced run —
    tracing is not free. Compare traces to traces.
  • Default perry binaries are stripped; the profile shows raw addresses. Build unstripped
    before symbolicating.

Context

Closed predecessors, for anyone reconstructing how we got here: #6893 (per-class layout
interning — the 289→133 B win), #7377 (nursery cap + scavenge default), #7148
(force_full_scan on every automatic GC), #7432 (adaptive tenuring + young-scoped cap),
#7443, #7449. Open and adjacent: #7438 (tree RSS cell: 193 MB scavenge-on vs 102 MB off),
#6759 (V8-style object model), #6827 / #1849 (representation selection — the long-term
home for workstream A item 4).

One deliberate non-goal: do not pursue reference counting. A head-to-head against
scriptc (a refcounting TS→native compiler, real-apps/scriptc) is in
gc-handoff/PROMPTS.md; its cycle collector turns a 1M-node linked list into 124.5 s
against Perry's 1.80 s. Perry's tracing GC is the right architecture — the gap in this
ticket is the allocation path, not the collection strategy.

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