Skip to content

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

Open
mcollina wants to merge 2 commits into
nodejs:mainfrom
mcollina:webstream-perf-round17
Open

mcollina wants to merge 2 commits into
nodejs:mainfrom
mcollina:webstream-perf-round17

Conversation

@mcollina

@mcollina mcollina commented Sep 16, 2026

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.


AI generated, humanly reviewed.

@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 <[email protected]>
@mcollina
mcollina force-pushed the webstream-perf-round17 branch from 9a19fef to 906ea04 Compare September 16, 2026 00:36
@mcollina
mcollina marked this pull request as ready for review September 17, 2026 08:08
@mcollina
mcollina requested review from H4ad, ShogunPanda, aduh95 and anonrig and removed request for H4ad September 17, 2026 08:08
@github-actions

github-actions Bot commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Benchmark GHA (webstreams): https://github.com/nodejs/node/actions/runs/35198908305

Results

[!WARNING]
Do not take GHA benchmark results as face value, always confirm them
using a dedicated machine, e.g. Jenkins CI.

Benchmark results:

                                                                       confidence improvement accuracy (*)    (**)   (***)
webstreams/adapters.js kind='readable-from-web' n=100000                               0.02 %       ±7.67% ±10.11% ±12.97%
webstreams/adapters.js kind='readable-to-web' n=100000                                 4.77 %      ±10.19% ±13.44% ±17.24%
webstreams/adapters.js kind='writable-from-web' n=100000                              -0.90 %       ±7.14%  ±9.41% ±12.08%
webstreams/adapters.js kind='writable-to-web' n=100000                                 0.16 %       ±7.91% ±10.42% ±13.37%
webstreams/creation.js kind='ReadableStream.tee' n=50000                       **      9.90 %       ±6.11%  ±8.05% ±10.34%
webstreams/creation.js kind='ReadableStream' n=50000                                  -1.71 %       ±5.41%  ±7.13%  ±9.15%
webstreams/creation.js kind='ReadableStreamBYOBReader' n=50000                         0.09 %       ±9.03% ±11.91% ±15.28%
webstreams/creation.js kind='ReadableStreamDefaultReader' n=50000                     -0.28 %       ±9.36% ±12.34% ±15.84%
webstreams/creation.js kind='TransformStream' n=50000                           *      6.75 %       ±6.36%  ±8.38% ±10.76%
webstreams/creation.js kind='WritableStream' n=50000                                   0.01 %       ±5.38%  ±7.09%  ±9.10%
webstreams/encoding-streams.js len=1024 kind='decode' n=100000                         0.90 %       ±4.53%  ±5.97%  ±7.66%
webstreams/encoding-streams.js len=1024 kind='encode' n=100000                         0.72 %       ±6.92%  ±9.12% ±11.70%
webstreams/encoding-streams.js len=16 kind='decode' n=100000                           1.65 %       ±4.74%  ±6.25%  ±8.02%
webstreams/encoding-streams.js len=16 kind='encode' n=100000                           0.07 %       ±5.15%  ±6.79%  ±8.72%
webstreams/from.js kind='async' n=1000000                                             -0.66 %       ±6.54%  ±8.62% ±11.06%
webstreams/from.js kind='sync' n=1000000                                               0.62 %       ±6.59%  ±8.68% ±11.14%
webstreams/js_transfer.js n=10000 payload='ReadableStream'                             6.19 %       ±9.42% ±12.42% ±15.94%
webstreams/js_transfer.js n=10000 payload='TransformStream'                            3.91 %       ±8.81% ±11.61% ±14.90%
webstreams/js_transfer.js n=10000 payload='WritableStream'                             3.98 %       ±9.31% ±12.27% ±15.75%
webstreams/lifecycle.js kind='pipe-through' n=50000                            **     10.23 %       ±6.82%  ±8.99% ±11.54%
webstreams/lifecycle.js kind='pipe-to' n=50000                                  *      9.22 %       ±7.13%  ±9.39% ±12.05%
webstreams/lifecycle.js kind='readable' n=50000                                       -0.99 %       ±6.04%  ±7.96% ±10.22%
webstreams/pipe-through.js kind='default' n=500000                                     1.94 %       ±6.69%  ±8.82% ±11.32%
webstreams/pipe-through.js kind='transform' n=500000                                  -0.39 %       ±6.63%  ±8.74% ±11.22%
webstreams/pipe-to.js highWaterMarkW=1 highWaterMarkR=1 n=500000                      -0.75 %       ±7.14%  ±9.41% ±12.08%
webstreams/pipe-to.js highWaterMarkW=1 highWaterMarkR=1024 n=500000                   -0.97 %       ±7.26%  ±9.57% ±12.29%
webstreams/pipe-to.js highWaterMarkW=1 highWaterMarkR=4096 n=500000                   -0.41 %       ±7.22%  ±9.52% ±12.21%
webstreams/pipe-to.js highWaterMarkW=1024 highWaterMarkR=1 n=500000                   -1.18 %       ±6.99%  ±9.21% ±11.82%
webstreams/pipe-to.js highWaterMarkW=1024 highWaterMarkR=1024 n=500000                -0.35 %       ±7.09%  ±9.35% ±11.99%
webstreams/pipe-to.js highWaterMarkW=1024 highWaterMarkR=4096 n=500000                -0.07 %       ±6.94%  ±9.15% ±11.74%
webstreams/pipe-to.js highWaterMarkW=4096 highWaterMarkR=1 n=500000                   -1.41 %       ±6.90%  ±9.09% ±11.67%
webstreams/pipe-to.js highWaterMarkW=4096 highWaterMarkR=1024 n=500000                 0.04 %       ±7.07%  ±9.31% ±11.95%
webstreams/pipe-to.js highWaterMarkW=4096 highWaterMarkR=4096 n=500000                -0.76 %       ±7.00%  ±9.23% ±11.84%
webstreams/readable-async-iterator.js type='bytes' n=100000                           -0.40 %       ±8.49% ±11.19% ±14.36%
webstreams/readable-async-iterator.js type='normal' n=100000                           0.15 %       ±6.75%  ±8.90% ±11.41%
webstreams/readable-read-buffered.js bufferSize=1 n=100000                            -0.35 %       ±6.76%  ±8.91% ±11.43%
webstreams/readable-read-buffered.js bufferSize=10 n=100000                           -0.71 %       ±6.26%  ±8.25% ±10.58%
webstreams/readable-read-buffered.js bufferSize=100 n=100000                          -0.51 %       ±6.55%  ±8.63% ±11.08%
webstreams/readable-read-buffered.js bufferSize=1000 n=100000                         -0.52 %       ±6.15%  ±8.11% ±10.41%
webstreams/readable-read.js type='byob' n=100000                                      -0.22 %       ±9.13% ±12.03% ±15.44%
webstreams/readable-read.js type='normal' n=100000                                     2.18 %       ±6.99%  ±9.21% ±11.82%
webstreams/tee.js type='bytes' n=100000                                               -1.12 %       ±9.90% ±13.06% ±16.75%
webstreams/tee.js type='normal' n=100000                                              -3.12 %       ±7.77% ±10.25% ±13.15%

Be aware that when doing many comparisons the risk of a false-positive
result increases. In this case, there are 43 comparisons, you can thus
expect the following amount of false-positive results:
  2.15 false positives, when considering a   5% risk acceptance (*, **, ***),
  0.43 false positives, when considering a   1% risk acceptance (**, ***),
  0.04 false positives, when considering a 0.1% risk acceptance (***)

[!WARNING]
Do not take GHA benchmark results as face value, always confirm them
using a dedicated machine, e.g. Jenkins CI.

Comment thread test/parallel/test-whatwg-webstreams-internal-construction.js Outdated
@aduh95 aduh95 added author ready PRs with CI started, the required approvals, and no outstanding review comments. request-ci Add this label to start a Jenkins CI on a PR. Only starts once the PR has an approving review. labels Sep 17, 2026
@github-actions github-actions Bot removed the request-ci Add this label to start a Jenkins CI on a PR. Only starts once the PR has an approving review. label Sep 17, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@codecov

codecov Bot commented Sep 17, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.27%. Comparing base (5c5bd22) to head (309d086).
⚠️ Report is 18 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main   #66052      +/-   ##
==========================================
+ Coverage   90.24%   90.27%   +0.03%     
==========================================
  Files         789      789              
  Lines      270613   271576     +963     
  Branches    51802    51851      +49     
==========================================
+ Hits       244208   245166     +958     
- Misses      16870    16871       +1     
- Partials     9535     9539       +4     
Files with missing lines Coverage Δ
lib/internal/webstreams/readablestream.js 98.05% <100.00%> (-0.02%) ⬇️
lib/internal/webstreams/util.js 97.11% <100.00%> (+0.01%) ⬆️
lib/internal/webstreams/writablestream.js 99.52% <100.00%> (-0.01%) ⬇️

... and 48 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

WPT already asserts the class of tee branches, ReadableStream.from()
results and TransformStream sides, and test-webstreams-clone-unref.js
covers transferred streams.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

author ready PRs with CI started, the required approvals, and no outstanding review comments. 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.

4 participants