node:events: emit() allocates no arguments array in top-tier code

Draft
RO
9d ago
+568-60

Problem

  • In top-tier code, emit() allocates an array for its arguments on every call that is not inlined: 10,003 arrays per 10,000 emits.
  • The cause is return emitError(this, args) (src/js/node/events.ts:387). A rest array that goes to a call has to exist.

Fix

  • emit() applies its arguments: emitErrorApplied.$apply(this, args). That function calls the listeners of "error" itself.
  • On bench/snippets/event-emitter-emit.mjs (from #45234) the 12 plain rows are 1.8 to 4.2 times faster, and faster than Node. Ahead of time the plain call stays: 0 of 126 event rows move.
  • Verified: test/js/node/events/event-emitter.test.ts (14 cases fail on main), test/js/node/events/, 86 upstream files, 9 ahead-of-time programs.
  • Self-reviewed: 34 concerns raised, 24 addressed. Left out: a pinned "error" row, numbers against Bun 1.4.4.

Background

  • A rest array needs no allocation while a function only indexes, measures or applies it.
  • JSC inlines four frames deep. A function that only forwards to emitError() puts the listener in frame five, and the "error" row of the bench takes 71 ns against 57. So the function calls the listeners: 45 ns.
  • This is change 2 of #45234 by @sosukesuzuki, on the two texts of emit() that #45631 brought. Considered a plain call whose result is used: the array is back after the first "error".

Downsides

  • A handled emit("error") costs more below the top tier: interpreter 2,909 to 3,199 instructions, baseline JIT 1,130 to 1,511.
  • In top-tier code a loop of "error" emits costs 48 to 58 instructions an emit, 127 with four arguments.
  • Emitters that capture rejections keep the array. #47381 removes their second emit().
Notes

How it is measured. Release builds with LTO for Linux x64 of main 77b380dcaf and of this branch. Instruction counts are of the first thread of the program in user mode (perf_event_open), with the JIT on that thread (BUN_JSC_useConcurrentJIT=0), least of three runs. A number per emit is the slope between two lengths of one loop.

Gain. bench/snippets/event-emitter-emit.mjs, which marks emit() as not inlined. p50 in ns, median of three runs:

rowmainPRnode v26.3.0
0 listeners, 0 arguments12.153.537.01
0 listeners, 1 argument10.543.688.88
0 listeners, 2 arguments11.043.746.81
0 listeners, 4 arguments15.303.947.15
1 listener, 0 arguments16.757.2910.33
1 listener, 1 argument14.647.5413.35
1 listener, 2 arguments14.116.9913.84
1 listener, 4 arguments28.197.0314.07
2 listeners, 0 arguments16.669.2718.71
2 listeners, 1 argument16.088.1321.26
2 listeners, 2 arguments16.788.1718.33
2 listeners, 4 arguments41.019.8321.86

The "error" row has two levels, by what the JIT compiled first. Average of a run in ns, 8 runs: main 54 to 63 (once 32), PR 43 to 47 (three times 13 to 15), node 16. With a function that only forwards to emitError(): 71 to 73 (three times 34 to 39).

mainPR
jit emit-to-one-listener.js, thousand instructions529,148322,546
jit emit-to-two-listeners.js739,844444,424
jit events-and-streams.js1,076,8161,052,906
collections in 2,000,000 emits where emit() is a call of its own, or the name of the event is no constant11 to 150

The PR lowers the three JIT rows of test/aot/perf/plain-bun-costs.linux-x64.json to these numbers.

What does not move:

  • An emit() that is inlined into its caller under a constant name never made the array: 32.0 instructions per emit on both.
  • Below the top tier the array stays. The interpreter rows do not move.
  • A program that reads 512 MB of lines through node:readline costs 1,520 instructions per line on main and 1,525 here, once both are in top-tier code. Main reaches that code later, after about 3 million lines, which is 1.6 billion instructions once (measured before the WebKit upgrade).

A handled emit("error"), instructions per emit. The middle column is a function that only forwards to emitError(): the first commit of this branch alone (+7/-1 in events.ts), measured before the WebKit upgrade, where main read as it reads now. It has the whole gain above.

shapetiermainforwards onlyPR
one listenertop48.0102.058.0
out of a method of a classtop81.0367.361.0
64 emitters in turntop158.0311.8147.5
two listenerstop162.0206.0100.0
emit() not inlinedtop108.0123.0121.0
with a listener of errorMonitortop116.0165.067.0
no argumenttop48.096.066.9
four argumentstop48.0120.9127.0
one listenerbaseline JIT1,130.31,796.91,510.7
two listenersbaseline JIT1,626.12,290.81,767.9
with a listener of errorMonitorbaseline JIT1,667.41,974.6
no argumentbaseline JIT1,008.81,547.7
four argumentsbaseline JIT1,279.02,190.6
one listenerinterpreter2,909.03,895.13,198.9
two listenersinterpreter4,011.94,998.13,924.7
with a listener of errorMonitorinterpreter4,052.05,037.74,097.2
no argumentinterpreter2,783.73,662.13,404.6
four argumentsinterpreter3,088.94,241.24,072.7

An emit("error") that nothing listens to, thrown and caught: top tier 1,673.5 to 1,702.3, baseline JIT 2,757.0 to 3,455.2, interpreter 4,646.8 to 5,720.7. No shape of "error" makes an array here, with or without a listener of errorMonitor. require("node:events") costs 3,573 thousand instructions on main and 3,576 here.

Why er is declared, and what two or more arguments cost. JSC inlines an applied call into a frame that has room for the most arguments it counted at that call, and for the declared ones at least. When the lower optimizing tier ran the call and did not inline it, the top tier reads a count of zero. The first call then leaves the compiled code (VarargsOverflow), and JSC does not inline that call again. With the one argument declared, the frame has room for it, and a listener takes it in a plain call. An emit("error") with two or more arguments still leaves the compiled code once and is a call of its own after that: the 127 above. With the JIT on its own threads, a loop of 20 million such emits that the JIT entered while it ran was compiled 4 times (main: once).

What stays the same. The function reads what emitError() read, once each and in its order: _events, then the listeners of errorMonitor, then those of "error". Four cases pin that for a Proxy in the place of the emitter and one in the place of _events, and test/js/node/stream/node-stream-proxy-traps.test.ts passes unchanged. 27 probes of the "error" road print the same lines on main, here under bun run, and here from a --compile --aot executable: a receiver that is undefined, null, a number, a plain object or a Proxy, no listener with an Error, a number and no argument, a listener that throws, removes or adds a listener, _events replaced or behind a Proxy, none to five arguments.

What changes beside the cost. A stack trace taken in a listener of "error" has one frame emit between the listener and the caller, as node has. On main it has two, applyHandlers and emitError. Both texts print the same: test/aot/parallel/library-modules/frames-of-the-listeners-of-error.cjs.

Ahead of time. bun test/aot/perf/compare-builds.ts --only 'built-in/events-|everyday-emitters|stream-new' --servers none: of 126 rows none moves. The compiled text keeps a plain call that takes the array, because a call in tail position that applies the array moves it to the heap there. An earlier text of this branch kept an object typed as an emitter in a module constant. The compiler read it, and 28 of 42 rows cost 180 instructions more a turn. test/aot/parallel/library-modules/an-emitter-in-a-loop.js holds counts that such an object exceeds.

Designs that were not taken.

  • The first commit alone: see the middle column. It makes the "error" row of the bench slower than main (71 ns against 57).
  • return emitError(this, args) === true keeps the plain call. JSC leaves a call whose result is used out of code that never ran it, so no array is made while no "error" was emitted, and "error" costs what it costs on main. After the first "error" the array is back for every emit.
  • Node's one road for all events: 50 instructions for a handled "error" against 58 here, but 32 against 28 for every plain emit after a few hundred handled errors.

Other PRs. This is change 2 of #45234, whose events.ts hunks no longer apply on main (3 of 8 fail since #45631). #45234 keeps seven other changes, and the half of this change for emitters that capture rejections. #47381 removes the second emit() of those emitters and goes on top of this PR.

Suites. test/js/node/events/ (192 pass). test/js/node/stream/ with the two Proxy trap files of test/js/node/http/ (218 pass, as on main). 86 files of test/js/node/test/parallel: test-event*, test-events*, test-stream-*error*, test-stream-pipeline*, test-http-*error*, test-net-*error*. Nine programs of test/aot/parallel/library-modules about emitters through test/aot/run-parallel.ts. test/aot/perf/plain-bun-costs.measure.ts with PLAIN_BUN_COSTS=hold: the rows of the event programs hold, and the top tier compiles no function twice. On this machine its peak-memory column and jit collector.js read high on main and here alike.