Skip to content

perf: class X extends EventEmitter is 440× slower than Node to construct and 200–2,400× to use (super() copies 15 bound closures onto every instance) #10508

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. super() in a subclass of the built-in EventEmitter calls
js_event_emitter_subclass_init, which allocates 15 bound closures (on, once, emit, …) and stores each as an
own property of the new instance. Constructing a 12-field Query costs ~704,000 instructions (1.1 M with
require("events")) vs 2,100 for the same class without extends, and afterwards every field write, field read and
emit on the instance takes the by-name slow paths.

Reproduction

ee.ts:

// pg lib/query.js / mysql2 Command shape: `class Q extends EventEmitter` whose constructor sets ~12 fields.
import { EventEmitter } from "node:events";
const variant = process.argv[2]; const N = Number(process.argv[3]);
class Plain { text: any; values: any; rows: any; types: any; name: any; queryMode: any; binary: any; portal: any; callback: any; _rowMode: any; _results: any; _canceled: any;
  constructor(i: number) { this.text = "SELECT"; this.values = [i]; this.rows = undefined; this.types = undefined; this.name = undefined; this.queryMode = undefined; this.binary = false; this.portal = ""; this.callback = null; this._rowMode = undefined; this._results = null; this._canceled = false; }
  requiresPreparation() { return this.values.length > 0; } }
class Q extends EventEmitter { text: any; values: any; rows: any; types: any; name: any; queryMode: any; binary: any; portal: any; callback: any; _rowMode: any; _results: any; _canceled: any;
  constructor(i: number) { super(); this.text = "SELECT"; this.values = [i]; this.rows = undefined; this.types = undefined; this.name = undefined; this.queryMode = undefined; this.binary = false; this.portal = ""; this.callback = null; this._rowMode = undefined; this._results = null; this._canceled = false; }
  requiresPreparation() { return this.values.length > 0; } }
const p = new Plain(1); const q = new Q(1); const keep: any[] = new Array(1024);
let got = 0; q.on("message", (m: number) => { got += m & 1; });
function run(n: number): number {
  let s = 0;
  if (variant === "new_ee") for (let i = 0; i < n; i++) { const o = new Q(i); keep[i & 1023] = o; s += o.values[0] & 1; }
  else if (variant === "new_plain") for (let i = 0; i < n; i++) { const o = new Plain(i); keep[i & 1023] = o; s += o.values[0] & 1; }
  else if (variant === "method_ee") for (let i = 0; i < n; i++) s += q.requiresPreparation() ? 1 : 0;
  else if (variant === "method_plain") for (let i = 0; i < n; i++) s += p.requiresPreparation() ? 1 : 0;
  else if (variant === "field_rw_ee") for (let i = 0; i < n; i++) { q.portal = i; s += q.text.length; }
  else if (variant === "field_rw_plain") for (let i = 0; i < n; i++) { p.portal = i; s += p.text.length; }
  else if (variant === "emit_ee") { for (let i = 0; i < n; i++) s += q.emit("message", i) ? 1 : 0; s += got; }
  return s;
}
run(N / 5 | 0); got = 0; const t0 = performance.now(); const cs = run(N);
console.log(`variant=${variant} checksum=${cs} ms=${(performance.now() - t0).toFixed(2)}`);
PERRY_NO_AUTO_OPTIMIZE=1 perry compile ee.ts -o ee
node ee.ts new_ee 50000; ./ee new_ee 50000          # other variants: N = 1000000 (emit_ee: 300000)

Measurements

Median of 3, shared host (loaded; instruction counts are the load-independent figure).

variant (N) Node loop ms Perry loop ms ratio Perry instructions / iter Node wall Perry wall
new Q(i) extends EventEmitter (50k) 11.1 4,879 440× 703,965 146 ms 5,852 ms
new Plain(i) (1M, control) 33.0 158.8 4.8× 2,112 191 ms 237 ms
q.requiresPreparation() (1M) 1.1 482.2 447× 4,882 171 ms 622 ms
p.requiresPreparation() (1M, control) 1.1 31.3 29× 453 136 ms 95 ms
q.portal = i; q.text.length (1M) 0.8 1,828 2,374× 16,428 137 ms 2,251 ms
same on Plain (1M, control) 1.3 9.6 7.2× 163 155 ms 55 ms
q.emit("message", i), 1 listener (300k) 6.8 1,341 198× 36,696 187 ms 1,682 ms

Checksums identical. CommonJS form (const EventEmitter = require("events"); class Q extends EventEmitter {…}, same
constructor, new_ee, N = 50k): Node 10.1 ms, Perry 5,937 ms, 587×, 1,101,749 instructions per construction.
Node's loops for the method/field rows are JIT-inlined, so those ratios mostly show Perry's absolute cost; the
Perry-vs-Perry control gaps (333× instructions for construction, 100× for a field read+write, 11× for a method whose
body reads one field) are the mechanism's cost.

perf record (inclusive):

  • new_ee: constructor 89 % → js_class_field_add 43 % (proxy::create_data_property → set_field_by_name_object_tail
    42 %, build_create_data_descriptor 22 %, reflect_define_property 20 %), js_event_emitter_subclass_init 26.5 %
    (install_methods_on_existing_object 26 %), js_put_value_set 18.5 %, copying minor GC 9.5 %.
  • field_rw_ee: js_put_value_set_ic_miss 72 % → ordinary_set_with_receiver 58 % →
    create_or_update_receiver_property 47 %, get_property_attrs 22 %, own_descriptors_skip_key 20 %;
    get_field_ic_miss_impl 25 %.
  • method_ee: the call is direct; get_field_ic_miss_impl 79 % for this.values inside the body.
  • emit_ee: js_native_call_method 82 % → dispatch_handle 74 % → emit_stream_event 61 % → has_truthy_hidden
    31 %, listener_snapshot 26 %, by-name hidden-key reads js_object_get_field_by_name_f64 51 %.

Impact

From the audit profiles (v0.5.1587; io-group report):

  • pg 8.22 (pool.query × 5,000, 70.7× Node): EventEmitter-subclass construction (new Query(): super() + field
    defines) 12 % of CPU, EventEmitter emit/on/off 9.7 %, most of the 20 % "other IC-miss gets/sets" is on
    EventEmitter-subclass instances (Client.query, Query, Parser.handlePacket, Connection._send) — ≈ 15–20 % of
    the Perry-minus-Node time.
  • mysql2 3.23 (pool.execute × 5,000, 51× Node): Command/Execute/Prepare constructors 12.0 %, emit 2.9 %,
    plus IC misses on those instances — ≈ 12 %.
  • Every Node-style client library models connections/commands/streams as EventEmitter subclasses (ioredis, ws,
    undici, mongodb, nodemailer, …).

Mechanism

  • crates/perry-runtime/src/node_stream_constructors/builders.rs:82-94 (verified) js_event_emitter_subclass_init:
    install_methods_on_existing_object(obj, this, &emitter_methods(), &[]).
  • crates/perry-runtime/src/node_stream_dispatch.rs:156-208 (verified): for each of the 15 methods listed in
    emitter_methods() (crates/perry-runtime/src/node_stream_readwrite.rs:1862) it runs class_chain_overrides,
    allocates a closure (js_closure_alloc, :195), writes the bound this into its capture, and stores it with
    js_object_set_field_by_name (:206). Every instance therefore carries 15 extra own keys added by name, and 15
    closures of garbage per construction.
  • The subclass's own field initialisers then go through js_class_field_add
    (crates/perry-runtime/src/object/field_get_set/ic_miss.rs:1544, verified) → proxy::create_data_property
    (crates/perry-runtime/src/proxy.rs:1644) → [[DefineOwnProperty]] with a descriptor build, instead of the inline
    class-field store used by Plain (inferred: the instance's shape after super() no longer matches the class
    layout the inline store expects).
  • Afterwards the instance is off the class fast paths: sets take js_put_value_set_ic_miss →
    ordinary_set_with_receiver with per-key descriptor-attribute lookups, reads take get_field_ic_miss_impl
    (verified by profile; that the installed own-closure keys are what defeats the inline guards is inferred), and any
    dynamic method call on it scans the ~27 own keys (perf: obj.method() through the runtime dispatcher is ~3,000× slower than Node (two O(own-keys) string-compare scans per call before any cache) #10502).
  • emit is served by the stream emitter: emit_stream_event
    (crates/perry-runtime/src/node_stream_event_emitter.rs:745) reads listener state from hidden by-name keys
    (listener_snapshot :481, has_truthy_hidden crates/perry-runtime/src/node_stream_readwrite.rs:141) on every
    call.

What fast looks like

  • Install the EventEmitter surface once on a shared prototype object in the subclass's chain (as Node does) instead
    of per-instance own closures; keep listener storage in one internal slot (or a lazily created side object) rather
    than hidden by-name keys.
  • Class-field initialisers of a native-base subclass should use the same inline store as any class.
  • Targets on this benchmark: new_ee ≤ 3× new_plain instructions; field_rw_ee and method_ee ≤ 1.5× their
    Plain controls; emit_ee with one listener ≤ 1,000 instructions per call.

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 Oct 3, 2026

    @proggeramlug
    ContributorAuthor

    Partial progress in #11793 (instructions/op, main before → after): new of an EventEmitter subclass 557,688 → 151,225; method_ee 994 → 190; emit_ee 54,887 → 51,146. Still far above node; follow-up (EventEmitter methods on a shared prototype) in progress. Leaving open.

  3. proggeramlug commented on Oct 3, 2026

    @proggeramlug
    ContributorAuthor

    More progress in #11809 (EventEmitter methods on one shared prototype with node's _events store): EventEmitter-subclass new −49%, loop RSS 108→47 MB; emit −33%; commander −22%. Leaving open.

  4. proggeramlug commented on Oct 4, 2026

    @proggeramlug
    ContributorAuthor

    More progress in #11885 (by-name stores, literal definitions from the shape, builtin globals as static sites, Function.prototype intrinsic entries): Zod −14.6%, qs −10.4%, commander −7.5%, tsc −0.01% (fulls 76=76), RSS lower on all four (n=5). Leaving open.

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