Repository navigation
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
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_add43 % (proxy::create_data_property→set_field_by_name_object_tail
42 %,build_create_data_descriptor22 %,reflect_define_property20 %),js_event_emitter_subclass_init26.5 %
(install_methods_on_existing_object26 %),js_put_value_set18.5 %, copying minor GC 9.5 %.field_rw_ee:js_put_value_set_ic_miss72 % →ordinary_set_with_receiver58 % →
create_or_update_receiver_property47 %,get_property_attrs22 %,own_descriptors_skip_key20 %;
get_field_ic_miss_impl25 %.method_ee: the call is direct;get_field_ic_miss_impl79 % forthis.valuesinside the body.emit_ee:js_native_call_method82 % →dispatch_handle74 % →emit_stream_event61 % →has_truthy_hidden
31 %,listener_snapshot26 %, by-name hidden-key readsjs_object_get_field_by_name_f6451 %.
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, EventEmitteremit/on/off9.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/Prepareconstructors 12.0 %, emit 2.9 %,
plus IC misses on those instances — ≈ 12 %. - Every Node-style client library models connections/commands/streams as
EventEmittersubclasses (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 runsclass_chain_overrides,
allocates a closure (js_closure_alloc,:195), writes the boundthisinto 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 byPlain(inferred: the instance's shape aftersuper()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_receiverwith per-key descriptor-attribute lookups, reads takeget_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). emitis 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_hiddencrates/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_plaininstructions;field_rw_eeandmethod_ee≤ 1.5× their
Plain controls;emit_eewith one listener ≤ 1,000 instructions per call.
Notes
- The per-instance install exists for correctness history: codegen: scalar replacement drops runtime-installed own properties —
class X extends EventEmitter { a = 1 }readstypeof x.emitas undefined #6343 (closed; scalar replacement dropped the installed
own properties), runtime: indirect subclass of a native base inherits nothing (class D extends B,class B extends EventEmitter) #6326 (closed; indirect subclasses inherited nothing), class extending EventEmitter/Map/Set/Event loses its native base when constructed from another module (blocks the OpenCode TUI) #10300 (closed; subclass constructed from
another module lost its base). A prototype-based install must keep those tests green. - Open correctness issue on neighbouring bases: http.ServerResponse and stream.Readable have empty prototypes: subclasses built with
util.inherits+Base.call(this),setPrototypeOforclass extends ServerResponsehave no setHeader/end/push (light-my-request / fastifyinject()never settles) #10454 (http.ServerResponse/stream.Readablehave empty
prototypes forutil.inheritssubclasses). - Sibling drafts: 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 (method calls on instances with many own keys), perf: adding a property witho.k = vis ~100× slower than Node (static-key write IC primes only overwrites; no add-transition cache) #10496 (property adds never cached).
Activity
proggeramlug commentedon Oct 3, 2026
Partial progress in #11793 (instructions/op, main before → after):
newof 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.perf: EventEmitter methods on one shared prototype, node's _events store
proggeramlug commentedon Oct 3, 2026
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.perf(object): a definition finds a listed key by content when it was …
proggeramlug commentedon Oct 4, 2026
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.
perf(events): EventEmitter is an ordinary object on the shared prototype
perf(events): EventEmitter operations answered by the shapes
proggeramlug commentedon Oct 10, 2026
Current numbers (2026-10-10): building and parsing 5,500 commander
Command/Optionobjects costs 6.61 G instructions on Perry vs 1.46 G on Node (4.5×).Constructing
class Command extends EventEmitter:ctor_chain_can_replace_thisis true for a native parent (lower_call/new_helpers.rs:698-727). So aftersuper(), each of commander's ~40 inferred fields is defined through a runtimejs_class_field_add(this, key, undefined)(lower_call/field_init.rs), and then stored again through the store IC.Shape and key-add work is only about 3–5% of commander's gap, though. Its profile is dominated by regex (24%), method-site misses (13%) and allocation (8%).
Fixing #12327 (keys enter the shape at the first real store) should also remove these runtime adds, because the assignments would then key-add through the store IC's inline add-ways.