You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
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
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.
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.
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, …).
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).
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_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_plain instructions; field_rw_ee and method_ee ≤ 1.5× their
Plain controls; emit_ee with one listener ≤ 1,000 instructions per call.
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.
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.
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.
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-inEventEmittercallsjs_event_emitter_subclass_init, which allocates 15 bound closures (on,once,emit, …) and stores each as anown property of the new instance. Constructing a 12-field
Querycosts ~704,000 instructions (1.1 M withrequire("events")) vs 2,100 for the same class withoutextends, and afterwards every field write, field read andemiton the instance takes the by-name slow paths.Reproduction
ee.ts:Measurements
Median of 3, shared host (loaded; instruction counts are the load-independent figure).
new Q(i)extends EventEmitter (50k)new Plain(i)(1M, control)q.requiresPreparation()(1M)p.requiresPreparation()(1M, control)q.portal = i; q.text.length(1M)Plain(1M, control)q.emit("message", i), 1 listener (300k)Checksums identical. CommonJS form (
const EventEmitter = require("events"); class Q extends EventEmitter {…}, sameconstructor,
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_tail42 %,
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_hidden31 %,
listener_snapshot26 %, by-name hidden-key readsjs_object_get_field_by_name_f6451 %.Impact
From the audit profiles (v0.5.1587; io-group report):
pool.query× 5,000, 70.7× Node):EventEmitter-subclass construction (new Query(): super() + fielddefines) 12 % of CPU, EventEmitter
emit/on/off9.7 %, most of the 20 % "other IC-miss gets/sets" is onEventEmitter-subclass instances (
Client.query,Query,Parser.handlePacket,Connection._send) — ≈ 15–20 % ofthe Perry-minus-Node time.
pool.execute× 5,000, 51× Node):Command/Execute/Prepareconstructors 12.0 %, emit 2.9 %,plus IC misses on those instances — ≈ 12 %.
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 inemitter_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 withjs_object_set_field_by_name(:206). Every instance therefore carries 15 extra own keys added by name, and 15closures of garbage per construction.
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 inlineclass-field store used by
Plain(inferred: the instance's shape aftersuper()no longer matches the classlayout the inline store expects).
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 everycall.
What fast looks like
of per-instance own closures; keep listener storage in one internal slot (or a lazily created side object) rather
than hidden by-name keys.
new_ee≤ 3×new_plaininstructions;field_rw_eeandmethod_ee≤ 1.5× theirPlain controls;
emit_eewith one listener ≤ 1,000 instructions per call.Notes
class X extends EventEmitter { a = 1 }readstypeof x.emitas undefined #6343 (closed; scalar replacement dropped the installedown 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 fromanother module lost its base). A prototype-based install must keep those tests green.
util.inherits+Base.call(this),setPrototypeOforclass extends ServerResponsehave no setHeader/end/push (light-my-request / fastifyinject()never settles) #10454 (http.ServerResponse/stream.Readablehave emptyprototypes for
util.inheritssubclasses).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).