You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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):
_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_alloc → arena_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:
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.
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.
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.
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.
~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
forfin churn retain retain1 tree cycles deeplist;do
PERRY_NO_AUTO_OPTIMIZE=1 <repo>/target/release/perry $f.ts -o n_$fdone# 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,jsonr=[(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
_tlv_get_addr drops below 5% of self time on the churn.ts profile (from 34.2%).
churn.ts wall time improves by ≥2× (4.33 s → ≤2.2 s). Stretch: within 5× of Node
(≤0.9 s).
tree.ts and retain.ts improve measurably and neither regresses on peak RSS.
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.
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.
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 isnow 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)Best-of-3 wall,
/usr/bin/time -lpeak RSS. Programs ingc-handoff/bench/.GC work per program is now small —
PERRY_GC_TRACE=1, summingcopying_nursery:treecopying is down 258× from the pre-#7432 baseline (4.39 GB / 61.0 Mobject-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
sampleover a 3 s window of./n_churn(2015 leaf samples):_tlv_get_addrappears at 41 distinct call-graph sites — this is diffuse, not onehot 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 fileson the object-allocation path:
.with(call sitescrates/perry-runtime/src/gc/layout.rscrates/perry-runtime/src/gc/barrier.rscrates/perry-runtime/src/arena/allocators.rsA single
js_object_alloc→arena_alloc_gc→ store-fields sequence touches several ofthese independently:
ARENA_FREE_LIST_NONEMPTY,ARENA_FREE_LIST, the arena itself, theGC trigger state, the write barrier's remembered-set state, and the layout side table —
each its own
_tlv_get_addrround trip.arena_alloc_gcatcrates/perry-runtime/src/arena/allocators.rs:266already shows the pattern: thefree-list
Cellcheck was optimised down to "~1ns" per the comment, but it is still aseparate TLS resolution.
Note this cost is invisible to
PERRY_GC_TRACEaccounting — it is mutator time, notpause 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:
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 Ctxto the internal helpers. This turns N resolutions per allocation into one,and is the only approach that composes with inlining.
captures much of it where a call does several accesses back-to-back — but does nothing
across calls.
local-execavoids the accessorentirely. Check whether
-Z tls-model=local-execis usable for the runtime staticlibon Darwin and what it costs in toolchain terms (nightly-only today). Treat as a
measurement to inform 1, not a shipping plan.
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 aboveare 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-liveRSS) / 2M so baseline noise cancels:
This is already down from 289 B before #6893 interned the layout masks per class —
that change alone bought 2.2×. What remains:
GcHeader+ 32 BObjectHeader+INLINE_SLOT_FLOORslots.INLINE_SLOT_FLOOR = 4(crates/perry-runtime/src/object/mod.rs:24) and everyallocator 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_namewrites up toalloc_limitslots inline, and shrinking itblindly is a heap-buffer-overflow into adjacent arena objects.
ObjectHeader(object/mod.rs:1460):object_type,class_id,parent_class_id,field_count(4× u32) pluskeys_arrayandmetapointers. V8carries one map pointer.
parent_class_idis derivable from the class registry, and theArchitecture: 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.copies and array-growth garbage. Quantify it before optimising it.
ObjectMetais not a contributor — it is lazily allocated and null at construction(
object/alloc.rs:147and siblings).Repro
Acceptance criteria
_tlv_get_addrdrops below 5% of self time on thechurn.tsprofile (from 34.2%).churn.tswall time improves by ≥2× (4.33 s → ≤2.2 s). Stretch: within 5× of Node(≤0.9 s).
tree.tsandretain.tsimprove measurably and neither regresses on peak RSS.positive reclamation every cycle and max pause in the low milliseconds;
treecopyingvolume 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).
gc_ratchetprobes plus12_large_live_set(added by gc: adaptive tenuring + young-scoped scavenge cap (fixes the large-live-set scavenge regression) #7432) hold their results.test that an unknown-shape
{}still takes 8 inline property writes without corruptingadjacent arena objects.
cargo testworkspace sweep green (exclude cross-host UI crates on macOS).Traps
other agents; check
uptimefirst. Prefer user-CPU time and best-of-N; peak RSS and GCtraces are the load-independent signals.
perry-runtimeis rlib-only, the.acomes from the-staticwrappers:cargo build --release -p perry -p perry-runtime-static -p perry-stdlib-static.PERRY_NO_AUTO_OPTIMIZE=1on ad-hoc compiles, andrm -rf node_modules/.cache/perryafter switching compilers or you will link stale objects.
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16for anything you intend to trust —it miscompiles the release runtime.
MADV_FREEpages stay resident); the heap showsup as
Memory Tag 240, notMALLOC_*. Seedocs/src/internals/memory-model.md.total pausefrom aPERRY_GC_TRACE=1run can exceed the wall time of an untraced run —tracing is not free. Compare traces to traces.
perrybinaries are stripped; the profile shows raw addresses. Build unstrippedbefore 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_scanon 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 ingc-handoff/PROMPTS.md; its cycle collector turns a 1M-node linked list into 124.5 sagainst 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.