Repository navigation
perf: new Klass(v,w) is 63% slower than the equivalent object literal (28.5x vs 17.4x Node) #7512
Description
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_layoutpernew(gc/layout.rs:962) — emitted by
lower_call/new.rson 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 = vstores 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
- Profile
push_clssymbolicated and diff the group shares againstchurn_alloc. The
delta is 1.55 s of a 3.99 s run — it should be plainly visible, not subtle. - Identify why the class path pays more, and fix it so that class construction is at
worst equal to the equivalent object literal. - 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
push_cls.tsis no slower thanchurn_alloc.tson a quiet host (today 3.99 s vs
2.44 s).push_cls.tsratio vs Node drops from 28.5× to at most the literal form's ratio.- A regression test pins class construction against object-literal construction so the
ordering cannot silently invert again. - No GC-behaviour drift:
PERRY_GC_TRACE=1on churn still ~105 cycles / ~0.004 GB copied
with positive reclamation every cycle. cargo testworkspace 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. Usessh 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=1at compile time, orsampleoutput is all???.- Rebuild runtime and stdlib (the
.acomes from the-staticwrapper crates);
PERRY_NO_AUTO_OPTIMIZE=1on ad-hoc compiles;rm -rf node_modules/.cache/perryafter
switching compilers; neverCARGO_PROFILE_RELEASE_CODEGEN_UNITS=16for measured builds. PERRY_WRITE_BARRIERS=0makes 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
perf(codegen): reach user-written constructors with the dead-field-in…
proggeramlug commentedon Aug 6, 2026
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 formsThe object literal is not a different construction path.
{v, w}is loweredto a synthesized anon-shape class, so both programs allocate with
js_object_alloc_class_inline_keys, call a<Class>_constructorsymbol, thenjs_gc_init_typed_shape_layout, thenjs_ctor_return_override. The per-chunkcall 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
{v, w}literalnew Node(v, w)js_typed_feedback_class_field_set_guardjs_class_field_set_fallbackjs_array_numeric_value_to_raw_f64Two of the class's four diamonds store a compile-time-constant
undefined(
0x7FFC000000000001) that the next two statements overwrite — exactly the deaddefault-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 theprologue on
Expr::PropertySet. No user syntax lowers to that node.perry-hir/src/lower/lower_expr/assignment.rs:86turns every source-levelobj.prop = value—this.v = vincluded — into the specPutValuenodeExpr::PutValueSet.Expr::PropertySetis emitted only by synthesized HIR,which is precisely what
mint_anon_shape_class's object-literal ctor is builtfrom. #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_f64set-guard cannot pass, and each dead writelands in
js_class_field_set_fallback=js_typed_feedback_record_fallback_calljs_object_set_field_by_name. A third probe with the sameclass declared
v: any; w: anypays the identical two dead diamonds, so this isnot 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
chunkIR emits, in this order:The instance has no typed descriptor and no
GC_OBJ_TYPED_LAYOUT_INTACTbitwhile the constructor body runs. So:
and i16 %r, 4096(INTACT) test cannot pass;class_field_fast_contractcallslayout_typed_raw_f64_slot_for_user, whichgoes through
with_typed_descriptor_for_queryand needs that same per-objectbit, 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 twostores pass their guard — because the anon-shape class's fields are typed
Any,so
requires_raw_f64 = 0and the guard only has to match class_id + keys.That is the inversion in one sentence: declaring the fields
numberis whatmakes 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_layoutpernewshould become a header bit-set"), with a sharper requirement thanperformance: 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 slotcurrently holds a plain double, and fresh slots hold
undefined, so anallocation-time install lands the object in
GC_LAYOUT_UNKNOWNand changesnothing. 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.tsno slower thanchurn_alloc.ts" is owned by cause 2,and cause 2 is #7510's subject. Recommend adding to #7510's task list:
#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 })vskeep.push(new Node(base + j, j)). Census= extract
<Class>_constructorfrom.perry-trace/llvm/*.llandgrep -oE "call [^@]*@[A-Za-z_][A-Za-z0-9_$.]*" | sort | uniq -c.test(codegen): pin accessor-invocation counts for the elided field in…
perf(codegen): reach user-written constructors with the dead-field-in…
test(codegen): pin accessor-invocation counts for the elided field in…
perf(codegen): reach user-written constructors with the dead-field-in…
docs(changelog): say why the ordering residual is tracked on #7510, not
docs(changelog): say why the ordering residual is tracked on #7510, not
perf(gc): make a repeat typed-shape construction a header bit-set (#7510
perf(codegen,gc): declare a class's typed-shape layout at allocation,…
perf(codegen,gc): declare a class's typed-shape layout at allocation,…
proggeramlug commentedon Aug 7, 2026
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
mainbehaviourbefore its change:
churn_allocpush_clsThe 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.tsandpush_cls.tsback-to-back onperry-macos.localat load ~1 and check whetherthe 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%), andlayout_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.mdfor thesame reason.
proggeramlug commentedon Aug 7, 2026
Confirmed by measurement — closing
Ran both benchmarks back-to-back against the same v0.5.1325 release build
(
--release, own build,PERRY_RUNTIME_DIRpinned to its owntarget/release):churn_alloc.tspush_cls.tsIdentical. 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.
proggeramlug commentedon Aug 7, 2026
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:Expr::PropertySetis what the compiler synthesizes (anon-shapeobject-literal constructors, the destructuring lowering).
Expr::PutValueSetis what user source lowers to —assignment.rsturnsevery source-level
obj.prop = value, includingthis.v = vin a hand-writtenconstructor, into the spec
PutValuenode. Nothing a user can type producesExpr::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
undefinedthat the next two statementsimmediately 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.
docs(plan): supersede the v0.5.1299 construction profile with the v0.…
perf(class): declare the typed shape at allocation for subclassed cha…
perf(class): declare the typed shape at allocation for subclassed cha…
perf(class): a subclassed hierarchy paid a by-name hash store per fie…