node:http: less work per request; events: emit() allocates no arguments array; faster header name lookup
What does this PR do?
Eight small changes on the path of a node:http request, one commit each. None changes behaviour. The first two undo deoptimizations, the rest removes work. Several reach beyond node:http: EventEmitter.prototype.emit, every Headers lookup, and the response head of Bun.serve.
1. The function that renders the response head stays optimized (_http_server.ts)
renderNativeHeaders computed ~~res._maxRequestsPerSocket, which is undefined unless server.maxRequestsPerSocket is set. In DFG code ~undefined leaves through an exit of kind Uncountable, which JSC neither counts against the speculation nor learns from. The function was compiled again with the same speculation, each time after twice as many calls. In 30,720 requests it was DFG-compiled 7 times, never reached FTL, and 95% of its samples were in the baseline JIT. The operand is now checked against undefined first; every other value still goes through ~~.
2. emit() allocates no array for its arguments (events.ts)
emit(type, ...args) handed args to emitError() in a tail call, and with captureRejections to addCatch(). An array that is passed on escapes, so JSC allocated it on entry to every call that was not inlined into its caller, whatever the event. args is now only indexed, measured and applied: emitError takes it through $apply, and addCatch gets a copy made at the point where a listener has returned a promise. It is not spread, which would go through Array.prototype[Symbol.iterator].
3. uWS writes a status line or a header line into the cork buffer in one step (AsyncSocket.h, HttpResponse.h)
writeStatus(), both writeHeader() and the Content-Length that end() adds called AsyncSocket::write() three or four times per line, and each call looked up the loop data, the backpressure buffer and the cork slot. AsyncSocket::writeParts() looks them up once and copies the parts when all of them fit; otherwise it makes the same write() calls as before. Bun.serve goes through the same functions.
4. The native writeHead finds the names that decide the framing by their length (NodeHTTP.cpp)
It looked every response header name up in the table of well-known names to tell Content-Length and Transfer-Encoding from the rest. It now compares only names of 14 and 17 characters. The Date case and the writeMark() call are gone because they did nothing: the function sets HTTP_WROTE_DATE_HEADER before it reads the first header.
5. findHTTPHeaderName hashes at most five bytes of a name and compares it in one call (HTTPHeaderNames.gperf, HTTPHeaderNames.cpp)
This function is behind every get, has, set and append of Headers, and behind req.headers / req.rawHeaders. Its table was generated with --key-positions='*', so the hash loaded a table entry for every byte of the name, and the comparison took both strings through another table a byte at a time. Generated with gperf's own choice of positions (bytes 1, 6, 14, 22 and the last, plus the length) the table has no collisions either, and its key range is 189 instead of 771. Lengths are compared first, then one memcmp settles a name spelled as the table spells it; any other case takes a loop without table loads. HTTPHeaderNames.cpp is gperf -D -s 2 on the .gperf file, then clang-format; before this PR the checked-in file was also exactly gperf's output.
6. req.headers finds the built-in rawHeaders accessor without a host call (_http_incoming.ts)
The getter asked __lookupGetter__ whether reading rawHeaders would reach the built-in accessor. That accessor is not configurable, so it is reached unless an object below IncomingMessage.prototype has a rawHeaders of its own. A walk up the prototype chain with hasOwnProperty answers that, optimized code does it inline, and it works at any depth (Express puts two prototypes in between). Where it says no, the headers are folded in JS as before.
7. req.headers takes no reference on the identifier of a well-known name (NodeHTTP.cpp): an atomic increment and decrement per header.
8. IncomingMessage and removeAllListeners() read their arguments without an arguments object (_http_incoming.ts, events.ts)
A function that mentions arguments builds the object on entry in the interpreter and the baseline JIT, which is where a server runs its first few hundred requests. Both are called once per request.
Numbers
Linux x64 (Xeon 8488C), release builds of 408c9374b26 with and without this branch. The machine is shared, so user-mode instructions of the JS thread (perf_event_open, kernel excluded) are given next to times; two copies of the same binary differ by at most 0.1% in instructions.
bench/snippets/event-emitter-emit.mjs (an emit that is not inlined into its caller, which is how a program with many emitters runs it; median of 5 interleaved runs):
| main | this PR | ||
|---|---|---|---|
| 0 listeners, 0 arguments | 16.60 ns | 3.42 ns | -79% |
| 0 listeners, 1 argument | 16.36 ns | 3.59 ns | -78% |
| 0 listeners, 2 arguments | 7.48 ns | 3.71 ns | -50% |
| 0 listeners, 4 arguments | 9.93 ns | 4.12 ns | -59% |
| 1 listener, 0 arguments | 21.34 ns | 6.19 ns | -71% |
| 1 listener, 1 argument | 19.96 ns | 9.26 ns | -54% |
| 1 listener, 2 arguments | 10.87 ns | 9.21 ns | -15% |
| 1 listener, 4 arguments | 28.97 ns | 12.21 ns | -58% |
| 2 listeners, 0 arguments | 21.44 ns | 8.28 ns | -61% |
| 2 listeners, 1 argument | 22.82 ns | 10.83 ns | -53% |
| 2 listeners, 2 arguments | 15.03 ns | 11.01 ns | -27% |
| 2 listeners, 4 arguments | 45.35 ns | 13.32 ns | -71% |
"error", 1 listener | 35.60 ns | 15.70 ns | -56% |
bench/snippets/headers-known-names.mjs (median of 5 interleaved runs):
| main | this PR | ||
|---|---|---|---|
has() x 7, Title-Case | 152.9 ns | 103.3 ns | -32% |
has() x 7, lowercase | 166.0 ns | 142.7 ns | -14% |
has() x 7, not well-known | 179.5 ns | 170.5 ns | -5% |
new Headers() of 7, Title-Case | 741 ns | 696 ns | -6% |
new Headers() of 7, lowercase | 729 ns | 734 ns | +1% |
new Headers() of 7, not well-known | 879 ns | 864 ns | -2% |
One round trip on a kept-alive connection, client and server on the same thread, so the client's share dilutes the change. The listener reads req.headers and answers with writeHead and 4 headers. Times are bench/snippets/node-http-server-roundtrip.mjs.
| main | this PR | ||
|---|---|---|---|
node:http, one kind of client | 51.5K instructions, 13.00 µs | 45.5K, 11.93 µs | -11.8%, -8.2% |
node:http, 40 kinds of client take turns | 54.4K instructions, 12.82 µs | 47.4K, 11.70 µs | -12.9%, -8.7% |
node:http, a client that repeats a header (headers folded in JS) | 59.4K instructions | 51.6K | -13.1% |
Bun.serve, a Response with 4 headers | 27.9K instructions | 26.8K | -3.9% |
Applications: a JSON API served to 16 kept-alive connections, 256 requests per iteration, 120 iterations in a fresh process. "Slowest 4" is the mean of the four slowest iterations after the first, which are the ones where the JIT is still warming up. Wall time in ms, mean of interleaved runs, client always on the main build.
| first iteration | slowest 4 | mean of the rest | ||
|---|---|---|---|---|
| Fastify with schemas and hooks (16 runs) | main | 43.9 | 15.40 | 8.53 |
| this PR | 44.4 | 15.21 (-1.2%) | 8.16 (-4.3%) | |
| main again | 45.0 | 15.59 | 8.59 | |
| Express (8 runs) | main | 43.4 | 15.00 | 9.62 |
| this PR | 43.9 | 14.68 (-2.1%) | 9.11 (-5.3%) | |
Hono on @hono/node-server (8 runs) | main | 67.0 | 25.62 | 14.72 |
| this PR | 66.6 | 24.74 (-3.4%) | 14.03 (-4.7%) |
The first iteration does not move, and for Fastify neither do the slowest 4: both differences are inside what two runs of main differ by.
The Fastify application commit by commit, in millions of user-mode instructions of the JS thread per iteration (median of 5 interleaved runs):
| first | 2nd to 5th | 61st to 120th | |
|---|---|---|---|
| main | 140.88 | 59.74 | 42.89 |
| main again | 140.87 | 59.68 | 42.88 |
| 1. response head stays optimized | 140.89 | 59.25 | 42.33 |
2. emit() | 141.00 | 59.16 | 42.17 |
| 3. uWS header lines | 140.67 | 58.87 | 41.94 |
4. writeHead names | 140.42 | 58.89 | 41.64 |
5. findHTTPHeaderName | 140.27 | 58.70 | 41.54 |
6. rawHeaders accessor | 140.28 | 58.58 | 41.51 |
| 7. identifier | 140.29 | 58.55 | 41.48 |
8. arguments | 140.09 | 58.47 | 41.42 |
| all | -0.6% | -2.1% | -3.4% |
Not changed, and known limits
- The two JSC behaviours behind 1 and 2 are still there for other code:
~xandx++on a value that folds toundefinedloop throughUncountableexits, and optimized code always takes the slow path to allocate an array of 0 or 1 elements (the inline path draws from a size class that the slow path never refills), which is why a rest array of that size costs twice one of 2. Both belong in WebKit. - With
captureRejections, anemit()whose listener returns a promise now copies its arguments: +2.6% instructions on that call. Every otheremit()on such an emitter stops allocating once a listener has returned a promise (-26%). - The first iteration of a cold server barely moves (-0.5% instructions). It is spent in the interpreter and the baseline JIT and in compiling, not in anything this PR touches.
How did you verify your code works?
Two tests fail without this PR; both look at the engine rather than at the clock:
event-emitter.test.ts, "emit() allocates no array for its arguments once it is optimized": countsheapStats().objectTypeCounts.Arrayaround 8,000 calls of anemitthat may not be inlined, on a plain emitter and on one withcaptureRejectionsafter a listener has returned a promise. 8,003 arrays on main, 3 here.node-http.test.ts, "the head of a response > is rendered by code that stays optimized": 200 requests in a child with low JIT thresholds, DFG restricted torenderNativeHeaders, andreportDFGCompileTimes. 3 compilations on main, 1 here. Skipped on debug builds, where the requests take seconds.
The others pin behaviour that has to stay, and pass on main as well:
event-emitter.test.ts: every argument ofemit("error", …)reaches the monitor and every listener, with 0, 1, 2 and 7 arguments, with and withoutcaptureRejections; what an unhandled"error"throws;emit("error")withArray.prototype[Symbol.iterator]replaced and deleted;removeAllListeners(undefined)againstremoveAllListeners(), with and without aremoveListenerlistener.node-http.test.ts, "the head of a response" (raw bytes over a socket): framing headers in lower, upper and mixed case and names of the same lengths that are not them; empty values, values and names of 20,000 bytes, a line of exactlyCORK_COPY_MAXbytes and of one more, 24 headers of 9 KB, each with the listener's and with the server'sContent-Length;Keep-Alive: timeout=5, max=Nforserver.maxRequestsPerSocketand forres._maxRequestsPerSocketset to a number, fractions above and below 1, a numeric string, 0, -1,nulland a word.node-http.test.ts, "req.headers and req.rawHeaders": an accessor forrawHeadersdefined on the request itself; 13 lists of names one after the other (prefixes of each other, other cases, other orders, same lengths, repeated names, names of 40, 41 and 256 bytes, 41 headers).headers.test.ts, "the names in HTTPHeaderNames.in": each of the names in four spellings, as Latin-1 and as UTF-16, is the same name; each name with one byte replaced (by another letter, and by the byte that differs in bit 5), with a byte added, removed, or-turned into_, is another name.
A node:test copy of the behaviour tests of node-http.test.ts and event-emitter.test.ts passes on Node 22.23 (18 of 18).
On a debug + ASAN build: event-emitter.test.ts, headers.test.ts and node-http.test.ts pass, apart from three tests of the last that listen on a unix socket, which my sandbox refuses. The first two files and the new tests of the third also pass with BUN_JSC_validateExceptionChecks=1, and the new tests of node-http.test.ts with BUN_JSC_slowPathAllocsBetweenGCs=3. An earlier state of the branch ran 104 files on debug (test/js/node/http, node/events, bun/http, bun/websocket, Express); what failed there were unix sockets, "all tests pass in Node.js" (my Node 22 cannot parse those files), and tests that hit their own timeouts on a debug build.
On release builds, 250 test files (test/js/node/http, node/events, node/stream, node/http2, bun/http, bun/websocket, web/websocket, web/fetch, Express) were run with and without the branch: the sets of failing tests are the same (all sandbox: unix sockets, IPv6, the network), except for the two tests above. Node's 492 test-http-*, test-https-* and test-event* files under test/js/node/test were run the same way: 13 fail with the branch, and the same 13 without it, plus test-http-keep-alive-timeout-race-condition.js in that one run.
Each commit builds on its own (release).