Skip to content

perf: new Klass(v,w) is 63% slower than the equivalent object literal (28.5x vs 17.4x Node) #7512

Closed

Description

@proggeramlug

Summary

Constructing a class instance is 63% slower than constructing the equivalent object
literal, on an otherwise identical workload. new Node(v, w) with two declared number
fields is 28.5× Node; the {v, w} literal is 17.4×.

That is backwards. A fixed-shape class with declared primitive fields is the most
statically-known construction form Perry has — known field count, known types, known
layout, a real constructor to attach a shape to. It should be the fastest path, not the
slowest.

This is filed separately from the two profile-driven sibling tickets because it looks
bug-shaped rather than optimisation-shaped, and may be a cheap bisect rather than a
project.

Evidence

Quiet M1 mini (perry-macos.local, load ~1.4), best-of-3 wall clock. Both programs do the
same thing — 20,000 rounds × 1000 objects pushed into a fresh local array:

variant Perry node scriptc ratio vs node
churn_alloc.ts — keep.push({ v: base + j, w: j }) 2.44 s 0.14 s — 17.4×
push_cls.ts — keep.push(new Node(base + j, j)) 3.99 s 0.14 s 0.46 s 28.5×
push_num.ts — keep.push(base + j) (no object) 0.30 s 0.11 s — 2.7×

Node treats the two forms as equivalent (0.14 s both). scriptc does push_cls in 0.46 s —
8.7× faster than Perry on the class form, while Perry beats scriptc comfortably on
pure compute (fib(40): 0.39 s vs 0.78 s). So this is specific to class construction.

push_cls.ts:

class Node {
  v: number;
  w: number;
  constructor(v: number, w: number) { this.v = v; this.w = w; }
}
function chunk(base: number): number {
  const keep: Node[] = [];
  for (let j = 0; j < 1000; j++) keep.push(new Node(base + j, j));
  return keep.length;
}
function main(): void {
  let total = 0;
  for (let c = 0; c < 20000; c++) total += chunk(c * 1000);
  console.log(total);
}
main();

Programs are in gc-handoff/bench/ (push_cls.ts, churn_alloc.ts, push_num.ts).

Where to look

The symbolicated profile of the literal form already shows a large typed-feedback
component that the class path plausibly pays more of:

  • js_typed_feedback_class_field_set_guard (typed_feedback/guards.rs:542) — 4.9%
  • typed_feedback::guards::class_field_* — 4.3%

Both are named for class-field stores yet appear on the object-literal benchmark;
profiling push_cls directly should show whether they grow. Other candidates worth ruling
in or out early:

  • js_gc_init_typed_shape_layout per new (gc/layout.rs:962) — emitted by
    lower_call/new.rs on every construction. Does the class path emit more layout work than
    the literal path, which can use the shape cache?
  • js_ctor_return_override (0.9% on the literal profile) — constructor-return semantics
    should be statically resolvable for a plain class.
  • Whether the constructor body's this.v = v stores go through the generic field-set path
    instead of a direct slot store. perf(codegen): elide provably-dead per-store bookkeeping on class-field stores #7486 elided "provably-dead per-store bookkeeping on
    class-field stores" — check whether its precondition actually holds here, because the
    measurement says something is still being paid.
  • Whether the class instance misses js_object_alloc_class_inline_keys' fast path
    (object/alloc.rs:242) that the literal hits.

Task

  1. Profile push_cls symbolicated and diff the group shares against churn_alloc. The
    delta is 1.55 s of a 3.99 s run — it should be plainly visible, not subtle.
  2. Identify why the class path pays more, and fix it so that class construction is at
    worst equal to the equivalent object literal.
  3. If the cause turns out to be the layout or barrier subsystems, close this as a duplicate
    of perf(gc): layout side tables are 34% of object construction — the construction/death half of #5094 (allocation is 7.7%) #7510 / perf(gc): write barriers cost 16% on an all-numeric store workload — elide on provably-non-pointer stores #7511 rather than fixing it twice — but record the finding, because
    "classes are slower than literals" is the symptom most likely to be noticed by users.

Acceptance criteria

  1. push_cls.ts is no slower than churn_alloc.ts on a quiet host (today 3.99 s vs
    2.44 s).
  2. push_cls.ts ratio vs Node drops from 28.5× to at most the literal form's ratio.
  3. A regression test pins class construction against object-literal construction so the
    ordering cannot silently invert again.
  4. No GC-behaviour drift: PERRY_GC_TRACE=1 on churn still ~105 cycles / ~0.004 GB copied
    with positive reclamation every cycle.
  5. cargo test workspace sweep green (exclude cross-host UI crates on macOS).

Traps

  • Do not benchmark on the dev Mac while builds run — load 15–140 from other agents
    makes wall clock meaningless. Use ssh perry@perry-macos.local (M1, 8 cores, idles
    ~1.5); Perry binaries are static arm64, so ship them over rather than building there.
    Fallback on a loaded host: best-of-N user CPU, within ~5% of the quiet host's wall
    clock.
  • PERRY_DEBUG_SYMBOLS=1 at compile time, or sample output is all ???.
  • Rebuild runtime and stdlib (the .a comes from the -static wrapper crates);
    PERRY_NO_AUTO_OPTIMIZE=1 on ad-hoc compiles; rm -rf node_modules/.cache/perry after
    switching compilers; never CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16 for measured builds.
  • PERRY_WRITE_BARRIERS=0 makes these benchmarks slower (it switches the collector out
    of evacuating mode) — it is not a way to isolate barrier cost.

Context

Sibling tickets from the same investigation: #7510 (gc::layout construction-path
cost, 33.6% of self time, under umbrella #5094) and #7511 (write barriers on
provably-non-pointer stores, 16.1%). Related: #7486 (class-field store elision), #7469 (_tlv_get_addr),
repsel 4a/4b (#6915, #6919), #6759 (object model).

Activity

  1. added
    performanceRuntime, compile-time, build-size, or memory performance
    on Aug 6, 2026
  2. proggeramlug commented on Aug 6, 2026

    @proggeramlug
    ContributorAuthor

    Diagnosis: two causes, one bug-shaped and one ordering-shaped

    Static diagnosis only — emitted-IR call censuses, no timings (this host was
    loaded; the numbers above came from the pinned quiet mini). Two probes differing
    only in construction form, --trace llvm, same compiler.

    First surprise: chunk() is identical in both forms

    The object literal is not a different construction path. {v, w} is lowered
    to a synthesized anon-shape class, so both programs allocate with
    js_object_alloc_class_inline_keys, call a <Class>_constructor symbol, then
    js_gc_init_typed_shape_layout, then js_ctor_return_override. The per-chunk
    call census is byte-identical between the two. The entire 63% lives inside
    the constructor.

    Cause 1 (bug-shaped, fixed in #7515): #7469's elision cannot see a user constructor

    per construction {v, w} literal new Node(v, w)
    field-store IC diamonds 2 4
    js_typed_feedback_class_field_set_guard 2 2
    js_class_field_set_fallback 2 2
    js_array_numeric_value_to_raw_f64 0 4
    ctor body, IR lines 111 314

    Two of the class's four diamonds store a compile-time-constant undefined
    (0x7FFC000000000001) that the next two statements overwrite — exactly the dead
    default-field-init write #7469 elides. It did not fire.

    ctor_prologue_param_assigned_fields
    (crates/perry-codegen/src/lower_call/field_init.rs:93, pre-fix) matched the
    prologue on Expr::PropertySet. No user syntax lowers to that node.
    perry-hir/src/lower/lower_expr/assignment.rs:86 turns every source-level
    obj.prop = value — this.v = v included — into the spec PutValue node
    Expr::PutValueSet. Expr::PropertySet is emitted only by synthesized HIR,
    which is precisely what mint_anon_shape_class's object-literal ctor is built
    from. #7469 was measured on the one form it could reach; its changelog's claim
    to cover "plain user ctors like constructor(a, b) { this.a = a; this.b = b }"
    was never true.

    Worse than "two extra calls": both dead diamonds take the cold arm on every
    single construction, not occasionally. A fresh instance carries no typed-shape
    descriptor, so a requires_raw_f64 set-guard cannot pass, and each dead write
    lands in js_class_field_set_fallback = js_typed_feedback_record_fallback_call

    • a linear-key-search js_object_set_field_by_name. A third probe with the same
      class declared v: any; w: any pays the identical two dead diamonds, so this is
      not a raw-f64 interaction — it is unconditional.

    #7515 extends the recognizer to both HIR spellings: 314 → 157 IR lines, 4
    diamonds → 2, matching the literal. Literal IR unchanged byte-for-byte.

    Cause 2 (ordering-shaped, NOT fixed — belongs with #7510)

    The residual is that the class's two real stores still miss too, and always
    will. The chunk IR emits, in this order:

    js_object_alloc_class_inline_keys   ; layout_init_pointer_free, no descriptor
    push_cls_ts__Node_constructor       ; every this.f = v store happens in here
    js_gc_init_typed_shape_layout       ; descriptor + GC_OBJ_TYPED_LAYOUT_INTACT
    js_ctor_return_override
    

    The instance has no typed descriptor and no GC_OBJ_TYPED_LAYOUT_INTACT bit
    while the constructor body runs. So:

    • the codegen inline precheck's and i16 %r, 4096 (INTACT) test cannot pass;
    • class_field_fast_contract calls layout_typed_raw_f64_slot_for_user, which
      goes through with_typed_descriptor_for_query and needs that same per-object
      bit, so the runtime guard cannot pass either.

    No raw-f64-declared class-field store inside any constructor can ever hit its
    fast path.
    With #7515 applied the two surviving stores take js_put_value_set
    (the full generic PutValue) on every construction, while the literal's two
    stores pass their guard — because the anon-shape class's fields are typed Any,
    so requires_raw_f64 = 0 and the guard only has to match class_id + keys.

    That is the inversion in one sentence: declaring the fields number is what
    makes the class slower.
    More type information selects a representation whose
    guard the construction path has made unsatisfiable.

    This is #7510's construction-path item ("js_gc_init_typed_shape_layout per
    new should become a header bit-set"), with a sharper requirement than
    performance: the descriptor must exist before the constructor body, not merely
    be cheaper. Moving the existing call earlier does not work —
    init_typed_shape_layout (gc/layout.rs:897) validates that each raw-f64 slot
    currently holds a plain double, and fresh slots hold undefined, so an
    allocation-time install lands the object in GC_LAYOUT_UNKNOWN and changes
    nothing. It needs either a prefill licensed by a by-construction proof that the
    prologue overwrites every raw-f64 field (the #7469 predicate is exactly that
    proof, now that it works on user ctors) or a live-header-tested declaration in
    the shape of #7501. Not a one-liner, and not worth forcing into #7515.

    Off-ramp recommendation

    Keep #7512 open until #7515 merges, then close it as fixed-in-part and fold
    the residual into #7510
    rather than tracking a third place. The acceptance
    criterion "push_cls.ts no slower than churn_alloc.ts" is owned by cause 2,
    and cause 2 is #7510's subject. Recommend adding to #7510's task list:

    1. The descriptor must be installed before the constructor body, not after
      it. Today no class-field store inside a constructor can pass its guard, so
      every declared-number field write in every constructor is a generic
      js_put_value_set.

    #7511 is not implicated: the barrier count is not what differs between the two
    forms (the literal emits more barrier work per store, and is still faster).

    Reproduction

    Probes and the census script are minimal; both are two files differing only in
    keep.push({ v: base + j, w: j }) vs keep.push(new Node(base + j, j)). Census
    = extract <Class>_constructor from .perry-trace/llvm/*.ll and
    grep -oE "call [^@]*@[A-Za-z_][A-Za-z0-9_$.]*" | sort | uniq -c.

  3. proggeramlug commented on Aug 7, 2026

    @proggeramlug
    ContributorAuthor

    The 63% gap appears to have closed already — re-measure before working this

    Not closing it myself, because I have not re-measured on the pinned host. But
    the evidence that it is stale is strong enough that nobody should start a bisect
    without checking.

    #7566 measured both of these shapes in the same run, on the same pinned quiet
    mini
    , as the baseline arm of its own A/B — i.e. current main behaviour
    before its change:

    bench #7566's "outlined" (pre-change) arm this issue's filing
    churn_alloc 1.32 s 2.44 s
    push_cls 1.30 s 3.99 s

    The class form is no longer slower than the object literal — it is marginally
    faster, and the 63% gap is gone.
    Both shapes also roughly halved in absolute
    terms. After #7566 landed they measure 0.73 s and 0.72 s respectively, still
    level with each other.

    Nothing in that window targeted this issue. The gap most plausibly closed as a
    side effect of the construction-path work that landed between the filing and
    #7566's measurement — #7474, #7486, #7487, #7501, #7525, #7532, #7535, #7536,
    #7552 — which is consistent with the issue's own framing that the cause might
    turn out to live in the layout or barrier subsystems and close as a duplicate.

    Caveat, stated so this comment is not itself a stale number. These are
    #7566's figures, not a measurement I took, and its A/B existed to compare
    allocator forms rather than to compare the two shapes against each other — so
    treat "1.32 vs 1.30" as a same-run, same-host comparison rather than a
    purpose-built one. The confirming step is cheap: run churn_alloc.ts and
    push_cls.ts back-to-back on perry-macos.local at load ~1 and check whether
    the ratio is still ~1.0.

    Flagging it because this campaign has now paid three times for working a ticket
    from a headline number that had already collapsed — #7510 (33.6% -> 11%),
    layout_forget_object (14.5% -> 3.0% -> 1.7%), and layout_note_slot
    (7.5% -> 0.03%, correctly closed with no code at all). #7572 added a standing
    "re-measure the row before profiling it" note to docs/engine-plan.md for the
    same reason.

  4. proggeramlug commented on Aug 7, 2026

    @proggeramlug
    ContributorAuthor

    Confirmed by measurement — closing

    Ran both benchmarks back-to-back against the same v0.5.1325 release build
    (--release, own build, PERRY_RUNTIME_DIR pinned to its own
    target/release):

    bench best-of-5 user CPU
    churn_alloc.ts 0.75 s
    push_cls.ts 0.75 s

    Identical. The class form is no longer slower than the object literal, so
    the defect this issue describes no longer exists.

    Method, stated because it is not the usual one. This was measured on the dev
    Mac under load ~8, not on the pinned quiet mini, so these are not publishable
    wall-clock figures. Best-of-5 user CPU is the fallback that stays meaningful
    under load, and the comparison here is a ratio between two shapes measured
    back-to-back with the same binary and method, which is exactly what this issue
    asks about. It also independently agrees with #7566's quiet-host numbers for the
    same two benches (0.73 s and 0.72 s), from a different host, method and run.

    The gap closed as a side effect of the construction-path work that landed
    between the filing and now — #7474, #7486, #7487, #7501, #7525, #7532, #7535,
    #7536, #7552, then #7566 — which matches the off-ramp written into the issue
    itself: "if the cause lands in the layout or barrier subsystems it closes as a
    duplicate."
    No bisect was needed and no code is owed.

    For the record the absolute numbers also moved a long way: the filing measured
    2.44 s and 3.99 s, and both shapes are now 0.75 s.

  5. proggeramlug commented on Aug 7, 2026

    @proggeramlug
    ContributorAuthor

    Correction to my close comment: this was not an incidental side effect. It
    was fixed on purpose by #7515 ("reach user-written constructors with the
    dead-field-init elision (#7512)"), with #7532 building on it. I attributed
    it to a diffuse list of construction-path PRs, which undersells the record.

    The root cause is worth stating in the issue, because it is more interesting
    than "it got faster" and it is documented in
    crates/perry-codegen/src/lower_call/field_init.rs:

    Two HIR shapes mean the same thing here, and matching only one of them is what
    #7512 was.

    Expr::PropertySet is what the compiler synthesizes (anon-shape
    object-literal constructors, the destructuring lowering).
    Expr::PutValueSet is what user source lowers to — assignment.rs turns
    every source-level obj.prop = value, including this.v = v in a hand-written
    constructor, into the spec PutValue node. Nothing a user can type produces
    Expr::PropertySet.

    So #7486's dead-field-init elision only ever matched the synthesized literal
    constructor and was structurally unreachable for the declared class it was
    documented as covering. That is precisely why the object literal was fast and
    the class was not — the class paid two extra full class-field-set IC diamonds
    per construction (a guard call plus a by-name fallback each, since a fresh
    instance has no typed-shape descriptor yet so the raw-f64 guard cannot pass)
    writing a compile-time-constant undefined that the next two statements
    immediately overwrite.

    The general lesson, which is the reason this took a separate ticket to notice:
    an unreachable predicate passes every soundness test there is. #7486 was
    correct in everything it asserted and still did nothing on the case it named,
    because no test asked whether the pattern it matched can occur. A predicate over
    a synthesized-vs-source IR distinction needs a test that the source form
    reaches it, not just that the transform is sound when it fires.

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