Skip to content

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

@proggeramlug

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.js defines every compiler object type as a
    class expression (47 class expressions, 3 declarations) — NodeObject constructor 7.4 %, SymbolObject 3.1 %,
    TokenOrIdentifierObject 2.0 % of samples by nearest JS frame, with js_put_value_set_ic_miss and
    publish_object_shape_from under 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_assigns and appends every top-level this.<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_assigns call sites are inside lower_class_decl (verified by grep).
  • With fields: 0, codegen has no slot layout for the constructor's this.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 with o.k = v is ~100× slower than Node (static-key write IC primes only overwrites; no add-transition cache) #10496). Profile of expr (verified):
    NodeExpr_constructor 90 % → js_put_value_set_ic_miss 89 % → js_put_value_set 86 % →
    ordinary_set_with_receiver 58 % → target_set 47 % → js_object_set_field_by_name 21 %, plus
    js_to_property_key/js_to_primitive 12 %, note_array_subclass_index_write 7 %.

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

Activity

  1. added
    performanceRuntime, compile-time, build-size, or memory performance
    package-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindings
    on Sep 17, 2026
  2. proggeramlug commented on Sep 26, 2026

    @proggeramlug
    ContributorAuthor

    Status as of main 2febf4214 (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's this.x = … stores hit the add memo. bench.js, per iteration: decl 1,526, expr 1,727, expr_fields equal to decl (base f6ad6defe before #11360: expr 12,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 tsc transpileModule (this issue's real workload) it was +3.9% on its base and +5.3% rebased on main. 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 (expr 1,727 vs decl 1,526, six adds). That goes to the key-add lane's add-cost work.

  3. added a commit that references this issue on Sep 26, 2026
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

    package-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindingsperformanceRuntime, 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