http: add native single-shot response builder#64393
Conversation
|
Review requested:
|
Serialize the common HTTP/1.1 header block (and optional body) in C++ so _storeHeader and ServerResponse.end avoid repeated JS string concatenation and multi-write path overhead. The public API and wire format are unchanged; complex cases fall back to the existing JS path. On benchmark/http/simple.js (bytes/4/1, 50 connections, 5s) this raised throughput from ~80k to ~118-126k req/s (~+48% to +58%) on the same machine. Signed-off-by: Yagiz Nizipli <yagiz@nizipli.com>
6e25748 to
8a0057a
Compare
Revert incidental ConnectionsList reformatting in node_http_parser.cc, document the buildHttpMessage flag and out-param slots in JS, and avoid Buffer.concat when pairing headers with a body by queueing a separate latin1 header write instead. Signed-off-by: Yagiz Nizipli <yagiz@nizipli.com>
Hot writeHead()+end() now flushes via tryFastFlushEnd instead of the full write_()/extra empty write path. Server-side native headers stay as Buffers (no latin1 string copy) until write time; the C++ builder writes into a single malloc transferred to Buffer (no std::string + Copy). Reuse a scratch flat-header array and a prebuilt 200 status line. Signed-off-by: Yagiz Nizipli <yagiz@nizipli.com>
mcollina
left a comment
There was a problem hiding this comment.
lgtm
This needs a CITGM run and some validation it doesn't break express, fastify, koa, etc before shipping.
|
The
notable-change
Please suggest a text for the release notes if you'd like to include a more detailed summary, then proceed to update the PR description with the text or a link to the notable change suggested text comment. Otherwise, the commit will be placed in the Other Notable Changes section. |
|
Also a benchmark CI run would be interesting. |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #64393 +/- ##
==========================================
- Coverage 90.25% 90.17% -0.08%
==========================================
Files 741 741
Lines 241207 242329 +1122
Branches 45430 45733 +303
==========================================
+ Hits 217698 218527 +829
- Misses 15084 15298 +214
- Partials 8425 8504 +79
🚀 New features to boost your workflow:
|
mcollina
left a comment
There was a problem hiding this comment.
I think this needs a few regression tests before landing. The risky area is not just performance; the native path must preserve the exact HTTP/1 framing behavior of the existing JS path.
Main cases I’d like covered:
res.end()with chunked framing but no body must still emit the terminating0\r\n\r\n.strictContentLengthmust still throw on the singleres.end(chunk)fast path.Trailermust keep the response chunked and must not be rewritten toContent-Length.Transfer-Encoding: chunked;...must still be recognized as chunked framing.- Large
Content-Lengthbookkeeping must not be truncated through native out-params.
Implementation note: native buffer bounds checks should avoid off + n <= est because that can overflow before the comparison; prefer subtraction-style checks like off <= est && n <= est - off or checked arithmetic.
Suggested regression file: test/parallel/test-http-outgoing-native-builder-regressions.js
'use strict';
const common = require('../common');
const assert = require('assert');
const http = require('http');
const net = require('net');
function rawRequest(handler, callback) {
const server = http.createServer(common.mustCall(handler));
server.listen(0, common.mustCall(() => {
const socket = net.connect(server.address().port, common.mustCall(() => {
socket.end('GET / HTTP/1.1\r\n' +
'Host: localhost\r\n' +
'Connection: close\r\n' +
'\r\n');
}));
socket.setEncoding('latin1');
let raw = '';
socket.on('data', (chunk) => raw += chunk);
socket.on('end', common.mustCall(() => {
server.close(common.mustCall(() => callback(raw)));
}));
}));
}
// Empty chunked responses must still terminate the chunked body.
rawRequest((req, res) => {
res.setHeader('Transfer-Encoding', 'chunked');
res.end();
}, common.mustCall((raw) => {
assert.match(raw, /\r\nTransfer-Encoding: chunked\r\n/i);
assert.match(raw, /\r\n0\r\n\r\n$/);
}));
// Advertising trailers requires chunked framing; do not rewrite to Content-Length.
rawRequest((req, res) => {
res.setHeader('Trailer', 'X-Checksum');
res.end('ok');
}, common.mustCall((raw) => {
assert.match(raw, /\r\nTrailer: X-Checksum\r\n/i);
assert.match(raw, /\r\nTransfer-Encoding: chunked\r\n/i);
assert.doesNotMatch(raw, /\r\nContent-Length:/i);
assert.match(raw, /\r\n2\r\nok\r\n0\r\n\r\n$/);
}));
// Transfer-Encoding parameters are valid transfer-coding parameters and must
// still be recognized as chunked framing.
rawRequest((req, res) => {
res.setHeader('Transfer-Encoding', 'chunked;foo=bar');
res.end('ok');
}, common.mustCall((raw) => {
assert.match(raw, /\r\nTransfer-Encoding: chunked;foo=bar\r\n/i);
assert.doesNotMatch(raw, /\r\nContent-Length:/i);
assert.match(raw, /\r\n2\r\nok\r\n0\r\n\r\n$/);
}));
// strictContentLength must be enforced for the single res.end(chunk) path too.
{
const server = http.createServer(common.mustCall((req, res) => {
res.strictContentLength = true;
res.setHeader('Content-Length', '4');
assert.throws(() => res.end('hello'), {
code: 'ERR_HTTP_CONTENT_LENGTH_MISMATCH'
});
res.destroy();
server.close(common.mustCall());
}));
server.listen(0, common.mustCall(() => {
const socket = net.connect(server.address().port, common.mustCall(() => {
socket.end('GET / HTTP/1.1\r\n' +
'Host: localhost\r\n' +
'Connection: close\r\n' +
'\r\n');
}));
socket.resume();
socket.on('close', common.mustCall());
}));
}
// Do not truncate Content-Length bookkeeping through native out-params.
{
const server = http.createServer(common.mustCall((req, res) => {
res.setHeader('Content-Length', '4294967296');
res.writeHead(200);
assert.strictEqual(res._contentLength, 4294967296);
res.destroy();
server.close(common.mustCall());
}));
server.listen(0, common.mustCall(() => {
const socket = net.connect(server.address().port, common.mustCall(() => {
socket.end('GET / HTTP/1.1\r\n' +
'Host: localhost\r\n' +
'Connection: close\r\n' +
'\r\n');
}));
socket.resume();
socket.on('close', common.mustCall());
}));
}Run with:
tools/test.py test/parallel/test-http-outgoing-native-builder-regressions.js|
+1 on the test concerns, notably we do already have a bunch of existing tests around all this stuff, so it would be really interesting to know which of those are now using the fast path or still on the old path after this change, because either:
A setup covering a bunch of the important cases structured such that they can run in fast path or slow path mode, to verify both paths against the same set of tests would be fantastic, if that's doable (or at least for some subset of the test cases). My biggest concern here is that these paths will diverge and create very confusing issues, so any way to bind them closer together would be great. |
Address review feedback: keep Trailer/TE-with-params on the chunked path, emit the empty chunked terminator from the single-shot fast path, enforce strictContentLength on res.end(chunk), avoid truncating large Content-Length values via Float64 out-params, and use overflow-safe buffer bounds checks. Add dual-path regression coverage. Signed-off-by: Yagiz Nizipli <yagiz@nizipli.com>
Keep writeHead()/_storeHeader() on the legacy JS path: the C++ builder was a net regression for small-header (simple.js) and many-header (headers.js) workloads. Reserve buildHttpMessage for tryFastEnd() single-shot end(body) with bodies up to 16KiB, and never concatenate larger payloads into a combined headers+body write. Also type buildHttpMessage on the http_parser binding. Signed-off-by: Yagiz Nizipli <yagiz@nizipli.com>
Avoid cork/uncork on single-write flushes, combine small Buffer bodies with the header into one write for the simple.js type=buffer path, and use corked dual writes (not uncorked queued writes) for large string bodies so end-vs-write-end stays competitive without O(n) copies. Signed-off-by: Yagiz Nizipli <yagiz@nizipli.com>
|
CI is red |
Applying your suggestion caused a regression in performance. Trying to fix. |
Summary
Add a C++
buildHttpMessagehelper used by the HTTP/1.1 outgoing path so the common cases avoid repeated JavaScript string concatenation and multi-write overhead:_storeHeader: builds the status/request line + header block natively (with JS fallback for special cases such as content-disposition latin1 / unique cookie joining).ServerResponse.end()fast paths:writeHead():tryFastFlushEndflushes the prebuilt header + body without the fullwrite_()/ extra empty-write path (this is thebenchmark/http/simple.jscase)._headercompatibility.Public API and on-the-wire format are unchanged.
Benchmarks
Machine-local
out/Releaseon the same host. Baseline measured before this change.benchmark/http/simple.js(primary)Other
simple.jsvariants (same tip):chunkedEnc=1(TE: chunked)len=1024content-lengthOther microbenches
end-vs-write-endmethod=endasc/64 c=50 duration=5end-vs-write-endmethod=writeasc/64 c=50 duration=5res.end('ok')only (50 clients, 3s)Review feedback (document flags, no incidental C++ reformat, no
Buffer.concat) is included; the common ASCII path still shows the large win vs baseline.Tests
Full
test/parallel/test-http*suite: 751/751 passed (pre-push on an earlier tip); targeted suite re-run after the latest speed-ups.Test plan
tools/test.py test/parallel/test-http* --mode=release(751 passed)benchmark/http/simple.jsbefore/after (and after review + further speed-ups)