Repository navigation
perf: new on a class expression is 159× slower than Node vs 7× for the identical declaration (constructor this.x = fields inferred only for declarations) #10499
Description
Found by the package performance audit (real npm packages compiled from source, profiled against Node 26.5.1) and
re-measured on Perry 7661bc0 (v0.5.1589), Linux x64. For a plain-JS class (no field declarations), HIR lowering
turns the constructor's top-level this.<name> = … assignments into class fields — but only in lower_class_decl.
A class expression (var NodeObject = class {…}, module.exports = class X {…}) is lowered with zero fields, so every
constructor store becomes a property ADD through full [[Set]] (≈ 4,800 instructions each) instead of a slot store.
Reproduction
bench.js (14 lines):
// typescript.js NodeObject shape (plain JS): `var X = class {...}` vs identical `class X {...}`
const variant = process.argv[2] || "expr"; const N = Number(process.argv[3] || "1000000");
class NodeDecl { constructor(kind, pos, end) { this.pos = pos; this.end = end; this.kind = kind; this.id = 0; this.flags = 0; this.parent = undefined; } }
var NodeExpr = class { constructor(kind, pos, end) { this.pos = pos; this.end = end; this.kind = kind; this.id = 0; this.flags = 0; this.parent = undefined; } };
var NodeExprFields = class { pos; end; kind; id; flags; parent;
constructor(kind, pos, end) { this.pos = pos; this.end = end; this.kind = kind; this.id = 0; this.flags = 0; this.parent = undefined; } };
const ring = new Array(4096).fill(null);
const V = {
decl(n) { let s = 0; for (let i = 0; i < n; i++) { const node = new NodeDecl(i & 63, -1, -1); node.pos = i; node.end = i + 5; ring[i & 4095] = node; s = (s + node.kind + node.end) % 1000003; } return s; },
expr(n) { let s = 0; for (let i = 0; i < n; i++) { const node = new NodeExpr(i & 63, -1, -1); node.pos = i; node.end = i + 5; ring[i & 4095] = node; s = (s + node.kind + node.end) % 1000003; } return s; },
expr_fields(n) { let s = 0; for (let i = 0; i < n; i++) { const node = new NodeExprFields(i & 63, -1, -1); node.pos = i; node.end = i + 5; ring[i & 4095] = node; s = (s + node.kind + node.end) % 1000003; } return s; },
};
V[variant](N / 5 | 0); const t0 = performance.now(); const cs = V[variant](N);
console.log(`variant=${variant} checksum=${cs} ms=${(performance.now() - t0).toFixed(2)}`);PERRY_NO_AUTO_OPTIMIZE=1 perry compile bench.js -o bench
for v in decl expr expr_fields; do node bench.js $v 1000000; ./bench $v 1000000; done
PERRY_NO_AUTO_OPTIMIZE=1 perry compile bench.js -o /tmp/x --print-hir | grep -E '^\s+- Node'Measurements
Median of 3, shared host (loaded; instruction counts are the load-independent figure). N = 1,000,000; Perry
instructions per iteration = (whole-process instructions:u − 38 M startup) / 1.2 M.
| variant | Node loop ms | Perry loop ms | ratio | Perry instructions (per iter) | Node wall ms | Perry wall ms |
|---|---|---|---|---|---|---|
decl (control: class NodeDecl {…}) |
28.3 | 193.0 | 6.8× | 2.17 G (1,775) | 127 | 264 |
expr (var NodeExpr = class {…}) |
26.2 | 4,149.0 | 159× | 37.06 G (30,850) | 129 | 4,999 |
expr_fields (same expression + pos; end; … declarations) |
24.6 | 173.4 | 7.1× | 2.17 G (1,775) | 105 | 247 |
Checksums identical. --print-hir lists NodeDecl (fields: 6), NodeExpr (fields: 0), NodeExprFields (fields: 6).
The six constructor stores cost ≈ 29,000 extra instructions in the expression (≈ 4,800 per store). A TypeScript version
with pos: number; … declarations is equally fast in both forms (1,128 instructions per iteration), because the
declarations supply the fields.
Impact
Audit profiles, v0.5.1587 (perf-large report):
- typescript 5.8.2
transpileModule: ≈ 15 % of Perry CPU.typescript.jsdefines every compiler object type as a
class expression (47 class expressions, 3 declarations) —NodeObjectconstructor 7.4 %,SymbolObject3.1 %,
TokenOrIdentifierObject2.0 % of samples by nearest JS frame, withjs_put_value_set_ic_missand
publish_object_shape_fromunder them. The audit's own 10-field microbenchmark measured 106× (expression) vs 3×
(declaration). - rate-limiter-flexible 11.2.0:
module.exports = class RateLimiterRes {…}etc. (control microbenchmark 1,513 ms
as an expression vs 138 ms as a declaration; overlaps the accessor cost of perf: class getters/setters are 430× (setter) to ~1,300× (getter) slower than Node (no accessor inline cache; every store runs the full OrdinarySet walk) #10498). - Any CommonJS or bundled code that exports classes as expressions (
exports.X = class {…}, esbuild/rollup output
var X = class { … }) is affected.
Mechanism
lower_class_decl(crates/perry-hir/src/lower_decl/class_decl.rs:54) scans each constructor statement with
collect_this_field_assignsand appends every top-levelthis.<ident> = …name not otherwise declared as a
ClassField(class_decl.rs:980-1044) (verified).- Class expressions are lowered by
lower_class_expr(crates/perry-hir/src/lower/lower_expr/arm_class.rs:8) →
lower_class_from_ast(arm_class.rs:129→class_decl.rs:1193), which has no equivalent scan; the only
collect_this_field_assignscall sites are insidelower_class_decl(verified by grep). - With
fields: 0, codegen has no slot layout for the constructor'sthis.x = …, so each store lowers to the generic
PutValue write PIC and, on a fresh instance, is an add that never primes (perf: adding a property witho.k = vis ~100× slower than Node (static-key write IC primes only overwrites; no add-transition cache) #10496). Profile ofexpr(verified):
NodeExpr_constructor90 % →js_put_value_set_ic_miss89 % →js_put_value_set86 % →
ordinary_set_with_receiver58 % →target_set47 % →js_object_set_field_by_name21 %, plus
js_to_property_key/js_to_primitive12 %,note_array_subclass_index_write7 %.
What fast looks like
Run the same constructor-assignment field inference for class expressions (share the scan between
lower_class_decl and lower_class_from_ast, with the same declared/inherited/accessor/method exclusions). Target:
expr equal to decl on this microbenchmark (≈ 1,800 instructions per iteration, from 30,850), and --print-hir
reporting fields: 6 for NodeExpr.
Notes
- The declared-fields variant proves the runtime path is fine once HIR has the layout; the gap is purely the missing
inference. - Open correctness issues on class expressions that touch the same lowering: Class expression returned from a function in a non-entry module has no per-evaluation identity: every call returns the same class, and the last
extendswins #10455 (per-evaluation identity), Inherited method of a capture-bearing class expression seesundefinedcaptures when called on an instance of a capture-bearingclass extendssubclass without a constructor #10486
(inherited method of a capture-bearing class expression seesundefinedcaptures), A function-localvarwritten from inside a class constructor/static method is not updated (class captures are by value), breaking emscripten'sFSinode counter #10489 (function-localvar
written from a class constructor). A fix here should keep their repros in view. - Shares are from the v0.5.1587 audit profile; ratios and instruction counts are re-measured on 7661bc0.
Activity
perf(hir): infer constructor this-fields for class expressions (#10499)
perf(hir): infer constructor this-fields for class expressions (#10499)
proggeramlug commentedon Sep 26, 2026
Status as of
main2febf4214(qb2, release build, output identical to node): the 159× cliff is gone at runtime. #11360's key-add path makes a constructor that adds keys produce a wide shape at birth, and the constructor'sthis.x = …stores hit the add memo.bench.js, per iteration:decl1,526,expr1,727,expr_fieldsequal todecl(basef6ad6defebefore #11360:expr12,487).PR #11369, which extends HIR constructor-field inference to class expressions, was merged and is being reverted in #11395. It made the fixture equal to
decl, but on tsctranspileModule(this issue's real workload) it was +3.9% on its base and +5.3% rebased onmain. It is also a static spelling rather than a shape fact. Details are on the PR.Still open here: the remaining ~200 instructions per iteration (
expr1,727 vsdecl1,526, six adds). That goes to the key-add lane's add-cost work.Revert #11369: class-expression constructor field inference regresses…