Repository navigation
perf: re-profile of construction on v0.5.1325 — typed_shape_layout_entry ~25% is the new top lever, js_array_length ~10-15% is unexplained #7578
Description
Fresh leaf profile of push_cls on v0.5.1325 — the construction picture has changed
The #7469 decomposition in docs/engine-plan.md is from the v0.5.1299 era and
its top rows are now closed. Re-profiled on current main (v0.5.1325,
f3d290874, own --release build, PERRY_DEBUG_SYMBOLS=1), two independent
sample runs:
| item | run 1 (766 leaf) | run 2 (379 leaf) |
|---|---|---|
gc::layout::typed_shape_layout_entry |
26.4% | 24.8% |
write barriers — js_write_barrier_slot + write_barrier_decoded_parent + barrier_child_prologue + incremental_mark_barrier_value |
25.0% | 26.9% |
user code — chunk + Node_constructor |
23.9% | 20.8% |
js_array_length |
10.2% | 15.0% |
js_ctor_return_override + constructor_return_overrides_this |
4.0% | ~3% |
is_registered_set + is_registered_map |
3.1% | — |
gc::layout_tables::layout_forget_object |
1.7% | 2.9% |
_tlv_get_addr |
1.0% | — |
Confirms the last campaign worked. _tlv_get_addr is 1.0%, from 27.0%
(#7565). layout_forget_object is 1.7–2.9%, from 14.5% (#7525/#7532).
Neither is a lever any more.
Method, stated plainly. Dev Mac under load ~8, not the pinned quiet mini, so
these are leaf-share figures and not publishable wall-clock. A share
decomposition is far more robust to external load than a timing is — the process
executes the same instructions either way — and the two runs agree within a few
points on every row. Re-measure on perry-macos.local before quoting absolute
times.
Two levers, and one surprise
1. typed_shape_layout_entry ~25% — the top single function
push_cls's Node takes the js_gc_declare_typed_shape_layout path
(verified in the emitted IR: 1 call to declare, 0 to init), so this is not
the ValidateSlots loop — #7515/#7532 are working as intended. It is the install
itself, which layout.rs:1022 documents as reducing, on a shape_install::hit,
to "the two header bit-writes shape_install_shared would have performed".
If that is what it costs on the hit path, why is it an FFI call at all? The
emitted call is
call void @js_gc_declare_typed_shape_layout(i64 %r55, i32 2,
ptr @perry_typed_shape_raw_f64_mask_push_cls_ts__Node, i32 1, ptr null, i32 0)— every argument but the object pointer is a compile-time constant for the
class. This is the exact shape #7566 just won 1.81× on: an FFI call whose real
cost is the call plus the thread-local resolutions it performs, where the work
itself is a couple of stores. The same remedy may apply (emit the hit path
inline, keep the call as the slow path), with the same size discipline —
#7566's loop-membership gate is the precedent for not paying bytes at every
site.
2. Write barriers ~25% — now tied for the top spot
Owned by #7511, and correctness-first: a missed barrier is a use-after-free,
not a slowdown. Noting only that it is no longer 16.1% — as a share it has grown
because everything around it shrank. PERRY_WRITE_BARRIERS=0 cannot be used to
bound it (it also switches the collector out of evacuating mode and makes the
bench slower).
3. js_array_length at 10–15% — the surprise
push_cls's inner loop is keep.push(new Node(v, w)). That an array-length read
is 10–15% of the profile is hard to justify: the length is a field of the
ArrayHeader the push already has in hand, and js_array_length is in the
checker's NONCOLLECTING set precisely because it is a plain accessor
(array/indexing.rs:537). This looks like one FFI call per push for a load that
should fold, and it is the cheapest of the three to investigate.
Not levers, but odd
is_registered_set + is_registered_map at ~3% in a benchmark containing
no Set and no Map. That is the shape #7510 already hit once — one immortal
side-table entry nullifying an is_empty() fast path — and worth a look while
someone is in this code.
Activity
proggeramlug commentedon Aug 7, 2026
Correction on the
js_array_lengthrow — I over-claimed it, and two isolation attempts both failedI called this one "the cheapest of the three to investigate". I investigated it,
and the honest answer is that it is anomalous and still unexplained — but the
framing in the issue body ("an array-length read is 10-15% of the profile") is
wrong and should not be acted on as written.
What is actually established:
push_clshas exactly onejs_array_lengthcall site in the emitted IR —return keep.length, executed 20,000 times, against 20,000,000 pushes.For 10-15% of the profile that is ~5.6 us per call, which is not a credible
cost for a tag check.
js_array_push_f64does not calljs_array_length(checked thedisassembly for
bledges), so the 20M pushes do not route through it._js_array_lengthbody —offsets
+108,+380,+600,+724within a symbol spanning0x1002a8180..0x1002a856c(1004 bytes). The disassembly at+600is realjs_array_lengthwork: NaN-box unmask,lsr x8, x0, #51tag extract, a magic-wordcompare. So it is not obviously a nearest-preceding-symbol artifact.
So: one call site, 20,000 executions, samples truly inside the function, and a
per-call cost that cannot be right. Something does not add up, and I have not
closed it.
Both isolation attempts failed, recorded so nobody repeats them:
Vary array size at constant total pushes — 5,000 chunks x 4,000 elements
instead of 20,000 x 1,000. Confounded: it moved the workload into a completely
different GC regime (leaf samples 379 -> 5,816; the top rows became btree
collections 12.4%,
_tlv_get_addr7.8%,classify_heap_space5.5%,remembered-set marking 4.6%). Changing array size changes liveness, so it is
not an A/B of
.lengthat all. Same family as the recorded trap that amulti-bench file stops being a valid instrument once one bench changes its
allocation rate.
A
.length-only microbenchmark — build one array, read.length20Mtimes. Measured 1.00 ns/iter for BOTH a 1,000-element and a 10-element
array, which looks like a clean O(1) proof and is not one: 1 ns/iter is an
empty loop.
keep.lengthis loop-invariant, so LLVM hoisted it and folded theaccumulation. Same trap as the recorded "loop-over-TLS benchmarks hoist the
resolution" and "constant-trip loops unroll" cases — a microbenchmark that
proves a runtime call is free has usually proved that it was deleted.
An isolation that would actually work has to keep liveness fixed and defeat
hoisting — e.g. vary only the number of
.lengthreads per chunk (1 vs 10)against an array the optimizer cannot prove unchanged, and check the share scales
with call count.
The other two rows are unaffected and remain the real levers:
typed_shape_layout_entry~25% (two runs, 26.4% / 24.8%, one call pernew,confirmed taking the
declarepath) and write barriers ~25% across four distinctsymbols. Both are ordinary attributions with plausible call counts.
docs(plan): supersede the v0.5.1299 construction profile with the v0.…
perf(gc): stop re-deriving compile-time mask facts on every construct…
perf(gc): stop re-deriving compile-time mask facts on every construct…
perf(gc): stop re-deriving compile-time mask facts on every construct…
perf(gc): stop re-deriving compile-time mask facts on every construct…
docs(plan): retract the unsound typed-shape inlining remedy (#7578) (#…
proggeramlug commentedon Aug 9, 2026
Closing — all three findings were acted on, and nothing is left dangling under this number.
b5c8e9c02(docs(plan): supersede the v0.5.1299 construction profile with the v0.5.1325 one (#7578) #7581).typed_shape_layout_entry~25%) — addressed bye4854c3e0(perf(gc): stop re-deriving compile-time mask facts on every construction (#7578) #7586);docs/engine-plan.md:165records "Partly addressed by perf(gc): stop re-deriving compile-time mask facts on every construction (#7578) #7586…push_cls1.091×".js_array_length10–15%, flagged unexplained) — resolved byaaea8b8f4(perf(codegen): elide the length computation of a statement-position push #7600),docs/engine-plan.md:50. That was the most interesting item here, so it's worth noting it got an actual explanation rather than being optimized around.d2dca5823(docs(plan): retract the unsound typed-shape inlining remedy (#7578) #7588), with a use-after-free rationale.A note for whoever writes the next one of these: this was a measurement snapshot at v0.5.1325 and main is now v0.5.1420, ~95 versions on. Its figures were already unverified when I picked it up today. Profile issues age faster than bug issues — worth stating the version and host inline (this one did, correctly) and re-measuring before coding to any row.