Skip to content

stream: trim per-stream costs in webstreams - #66052

Draft
mcollina wants to merge 1 commit into
nodejs:mainfrom
mcollina:webstream-perf-round17
Draft

mcollina wants to merge 1 commit into
nodejs:mainfrom
mcollina:webstream-perf-round17

Conversation

@mcollina

Copy link
Copy Markdown
Member

Round 17 of the webstreams performance work (follows #65625). This one targets the fixed per-stream cost of short-lived streams (create, a few chunks, close), which dominates once the per-chunk path is lean.

stream: unify internal webstream construction

Streams created internally (transform stream sides, tee branches, ReadableStream.from, transferred streams) were built by wrapper constructors that swapped the prototype of every instance and then assigned an own, enumerable constructor property. Each internal stream had its own hidden class, and Object.keys(new TransformStream().readable) reported ['constructor']. The public constructors now accept the internal construction sentinel and leave controller setup to the caller: one hidden class per stream type, no per-instance prototype swap, no own property. A test pins the observable part.

stream: trim webstream setup and teardown work

  • The queue ring buffer grew after a push filled it, so the initial 8-slot ring held only three (value, size) pairs and every four-chunk stream reallocated. Growing before the push lets it hold four.
  • pipeTo watched the source's closed promise with two reactions and, on teardown, let the reader and writer release paths probe and reject promise records only the pipe could observe. One reaction pair now watches the source, and finalize drops the records before release.

Benchmarks

node benchmark/compare.js --runs 30 webstreams, all 43 rows; only the significant ones listed, everything else is within noise:

                                                              confidence improvement accuracy
webstreams/creation.js kind='TransformStream' n=50000                ***     12.40 %       ±1.89%
webstreams/creation.js kind='ReadableStream.tee' n=50000             ***      6.73 %       ±1.80%
webstreams/js_transfer.js n=10000 payload='ReadableStream'           ***      7.51 %       ±1.45%
webstreams/js_transfer.js n=10000 payload='WritableStream'           ***      5.97 %       ±1.20%
webstreams/js_transfer.js n=10000 payload='TransformStream'          ***      3.66 %       ±1.06%
webstreams/lifecycle.js kind='pipe-through' n=50000                  ***      9.04 %       ±2.01%
webstreams/pipe-through.js kind='transform' n=500000                  **      3.78 %       ±2.19%
webstreams/pipe-through.js kind='default' n=500000                     *      2.30 %       ±2.00%
webstreams/readable-async-iterator.js type='normal' n=100000           *     -3.71 %       ±3.62%

The last row is untouched code and sits at the edge of its interval; with 43 rows one such * is the expected false positive.

The lifecycle benchmark drives its source through pull() one chunk at a time. A source that enqueues its chunks from start() (the common "body already in memory" shape) gains more, because it also hits the queue growth and the batch path: create → 4×1KB → close measured at +16% (getReader loop), +17% (pipeTo) and +20% (pipeThrough + pipeTo) on this machine, three interleaved runs each.

Ordering is unchanged: a 28-scenario microtask-ordering stress (start variants, transform start/cancel/error interleavings, pipeTo shutdown paths, tee, byte streams) logs identically against main, apart from the removed constructor key. WPT streams/compression/encoding and the webstreams parallel batch are green.

@nodejs-github-bot nodejs-github-bot added needs-ci PRs that need a full CI run. web streams Issues and PRs related to the Web Streams API. labels Sep 16, 2026
@mcollina
mcollina force-pushed the webstream-perf-round17 branch from ba231e2 to 9a19fef Compare September 16, 2026 00:35
Short-lived streams (create, a few chunks, close) pay a fixed cost per
stream that dominates once the per-chunk path is lean.

Streams created internally (transform stream sides, tee branches,
ReadableStream.from, transferred streams) were built by wrapper
constructors that swapped the prototype of every instance and then
assigned an own, enumerable `constructor` property to look like a
public stream. Each internal stream therefore had its own hidden class
and `Object.keys(stream)` reported `['constructor']`. The public
constructors now accept the internal construction sentinel and leave
controller setup to the caller, so every ReadableStream and
WritableStream shares one hidden class and no per-instance prototype
swap or own property is needed.

The queue ring buffer grew after a push filled it, so the initial 8-slot
ring held only three (value, size) pairs and a four-chunk stream
reallocated every time. Growing before the push lets the ring hold four
pairs.

pipeTo observed the source's closed promise with two reactions and, on
teardown, let the reader and writer release paths probe and reject
promise records that only the pipe could have observed. One reaction
pair now watches the source, and finalize drops the records before
release.

Signed-off-by: Matteo Collina <hello@matteocollina.com>
@mcollina
mcollina force-pushed the webstream-perf-round17 branch from 9a19fef to 906ea04 Compare September 16, 2026 00:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-ci PRs that need a full CI run. web streams Issues and PRs related to the Web Streams API.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants