Skip to content

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

Closed

Description

@proggeramlug

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

  1. proggeramlug commented on Aug 7, 2026

    @proggeramlug
    ContributorAuthor

    Correction on the js_array_length row — I over-claimed it, and two isolation attempts both failed

    I 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_cls has exactly one js_array_length call 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_f64 does not call js_array_length (checked the
      disassembly for bl edges), so the 20M pushes do not route through it.
    • The samples nonetheless land genuinely inside the _js_array_length body —
      offsets +108, +380, +600, +724 within a symbol spanning
      0x1002a8180..0x1002a856c (1004 bytes). The disassembly at +600 is real
      js_array_length work: NaN-box unmask, lsr x8, x0, #51 tag extract, a magic-word
      compare. 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:

    1. 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_addr 7.8%, classify_heap_space 5.5%,
      remembered-set marking 4.6%). Changing array size changes liveness, so it is
      not an A/B of .length at all. Same family as the recorded trap that a
      multi-bench file stops being a valid instrument once one bench changes its
      allocation rate.

    2. A .length-only microbenchmark — build one array, read .length 20M
      times. 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.length is loop-invariant, so LLVM hoisted it and folded the
      accumulation. 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 .length reads 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 per new,
    confirmed taking the declare path) and write barriers ~25% across four distinct
    symbols. Both are ordinary attributions with plausible call counts.

  2. proggeramlug commented on Aug 9, 2026

    @proggeramlug
    ContributorAuthor

    Closing — all three findings were acted on, and nothing is left dangling under this number.

    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.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions