Skip to content

stream: trim webstreams sink wrappers and BYOB transfers - #66702

Merged
nodejs-github-bot merged 1 commit into
nodejs:mainfrom
mcollina:webstream-perf-round23
Nov 9, 2026
Merged

nodejs-github-bot merged 1 commit into
nodejs:mainfrom
mcollina:webstream-perf-round23

Conversation

@mcollina

@mcollina mcollina commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

Round 23 of the webstreams performance work. It is stacked on #66643 (the first commit here is that PR's commit); only the second commit, stream: trim webstreams sink wrappers and BYOB transfers, is new.

Sink and transformer one-shot methods without wrappers

The close and abort methods of an underlying sink and the flush and cancel methods of a transformer were each wrapped at construction in an async closure kept by the controller for the stream's lifetime (two per WritableStream, two per TransformStream, with their contexts). They are now stored as-is in the controller state next to the object they belong to and called as that object's methods, with the spec's argument list, through a promiseCall helper that reproduces the reference implementation's adoption ticks (a synchronous throw becomes a rejection, a returned promise settles the caller two microtasks later, a plain value settles it at once).

The per-chunk methods (write, transform) keep their closures: the captured method is a constant for TurboFan there, and replacing the closure with a FunctionPrototypeCall on a stored method lost the inlining of the user function on the pipe-to rows. The source's cancel wrapper is also kept, because every variant that touched the readable stream's construction path shifted its inlining budget enough to show up in the creation rows.

Bytes allocated per stream with user methods: WritableStream 1040 → 844, TransformStream 1816 → 1720.

Unobservable BYOB transfers

Each BYOB read performed up to four ArrayBuffer transfers, and allocating an ArrayBuffer costs about half a microsecond here. Two of them are dropped:

  • ReadableByteStreamControllerConvertPullIntoDescriptor no longer transfers the descriptor's buffer before building the result view. By then nothing else can reach that buffer: it was transferred when the descriptor was created, and the BYOB request that exposes it is only ever invalidated together with a transfer.
  • On enqueue(), the first pending descriptor's buffer is only transferred when a BYOB request exposes it; without one there is no view to detach.

Verification

  • WPT streams, encoding and compression (286 files) and the parallel webstream, encoding, blob, fetch, stream and worker tests (426 files, including the new test-whatwg-webstreams-underlying-methods.js) pass.
  • A probe of 46 scenarios covering every user method (start/pull/cancel/write/close/abort/transform/flush) returning a value, a native promise, a thenable, throwing, or returning a rejected promise logs the same microtask tick for every observable step on the base and patched builds, as do the ordering probes from the previous rounds.

Benchmarks

benchmark/compare.js, 20 runs, against the previous commit:

                                                                       confidence improvement accuracy (*)    (**)   (***)
webstreams/readable-read.js type='byob' n=100000                              ***     17.14 %       ±1.80%  ±2.41%  ±3.17%
webstreams/readable-async-iterator.js type='normal' n=100000                           3.79 %       ±4.49%  ±6.02%  ±7.92%
webstreams/from.js kind='sync' n=1000000                                        *     -2.18 %       ±2.02%  ±2.70%  ±3.57%
webstreams/js_transfer.js n=10000 payload='TransformStream'                     *     -1.85 %       ±1.58%  ±2.12%  ±2.81%

The other 42 rows are within their confidence intervals. The two rows at the 5% level are the expected number of false positives for 46 comparisons; the transfer row is the one that could be real (it was between -1.5% and -2.7% at the 5% level in three runs), and I could not find a mechanism for it, since the transfer path only loses two closures per WritableStream.


AI Generated, reviewed by me.

@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 Oct 13, 2026
@mcollina
mcollina force-pushed the webstream-perf-round23 branch from 4fe400b to bf7c279 Compare October 26, 2026 15:54
The close and abort methods of an underlying sink and the flush and
cancel methods of a transformer were each wrapped at construction in an
async closure that the controller kept for the stream's lifetime: two
per WritableStream and two per TransformStream, each with its context.
Store these one-shot methods as-is in the controller state next to the
object they belong to, and call them as that object's methods with the
spec's argument list through a promiseCall helper that keeps the
reference implementation's adoption ticks. The per-chunk methods (write,
transform) keep their closures, which make the method a constant for the
optimizing compiler on the hot paths; so does the source's cancel
wrapper, since the readable stream's construction path is sensitive to
any change in its inlining budget.

In the byte controller, the transfer of a pull-into descriptor's buffer
when it is committed is dropped: nothing can reach that buffer any more,
since it was transferred when the descriptor was created and the BYOB
request that exposed it is only ever invalidated together with a
transfer. The transfer of the first pending descriptor's buffer on
enqueue is likewise only needed while a BYOB request exposes it.
Allocating an ArrayBuffer costs about half a microsecond, so those two
transfers were a large part of a BYOB read.

Bytes allocated per stream with user methods: WritableStream 1040 ->
844, TransformStream 1816 -> 1720. The microtask ordering of every user
method call is unchanged.

Benchmark results (20 runs, vs. the previous commit):

    webstreams/readable-read.js type='byob' n=100000           ***   17.14 %  ±1.80%
    webstreams/readable-async-iterator.js type='normal' n=100000        3.79 %  ±4.49%
    webstreams/from.js kind='sync' n=1000000                     *   -2.18 %  ±2.02%
    webstreams/js_transfer.js n=10000 payload='TransformStream'   *   -1.85 %  ±1.58%

The other 42 rows are within their confidence intervals; the two rows at
the 5% level are the expected number of false positives for 46
comparisons.

Signed-off-by: Matteo Collina <hello@matteocollina.com>
@mcollina
mcollina force-pushed the webstream-perf-round23 branch from bf7c279 to 678fa29 Compare October 26, 2026 16:54
@mcollina
mcollina marked this pull request as ready for review November 8, 2026 11:39
@codecov

codecov Bot commented Nov 8, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.68%. Comparing base (eb663fe) to head (678fa29).
⚠️ Report is 160 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main   #66702      +/-   ##
==========================================
+ Coverage   90.65%   90.68%   +0.02%     
==========================================
  Files         797      797              
  Lines      278268   279670    +1402     
  Branches    53514    53804     +290     
==========================================
+ Hits       252273   253619    +1346     
+ Misses      16377    16344      -33     
- Partials     9618     9707      +89     
Files with missing lines Coverage Δ
lib/internal/webstreams/readablestream.js 98.36% <100.00%> (+0.25%) ⬆️
lib/internal/webstreams/transformstream.js 98.81% <100.00%> (+0.01%) ⬆️
lib/internal/webstreams/util.js 98.08% <100.00%> (+0.88%) ⬆️
lib/internal/webstreams/writablestream.js 99.47% <100.00%> (-0.06%) ⬇️

... and 83 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.

@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 Nov 8, 2026
@github-actions

github-actions Bot commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

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

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.01 %       ±7.91% ±10.43% ±13.38%
webstreams/adapters.js kind='readable-to-web' n=100000                                -0.20 %      ±13.04% ±17.18% ±22.05%
webstreams/adapters.js kind='writable-from-web' n=100000                              -3.68 %       ±6.72%  ±8.85% ±11.36%
webstreams/adapters.js kind='writable-to-web' n=100000                                 1.12 %       ±9.01% ±11.87% ±15.23%
webstreams/creation.js kind='ReadableStream.tee' n=50000                               1.99 %       ±6.86%  ±9.04% ±11.61%
webstreams/creation.js kind='ReadableStream' n=50000                                   2.66 %       ±7.16%  ±9.44% ±12.12%
webstreams/creation.js kind='ReadableStreamBYOBReader' n=50000                         1.72 %       ±7.30%  ±9.62% ±12.34%
webstreams/creation.js kind='ReadableStreamDefaultReader' n=50000                     -0.24 %       ±7.51%  ±9.90% ±12.70%
webstreams/creation.js kind='TransformStream' n=50000                                  0.12 %       ±7.42%  ±9.78% ±12.55%
webstreams/creation.js kind='WritableStream' n=50000                                  -0.61 %       ±6.71%  ±8.85% ±11.36%
webstreams/encoding-streams.js len=1024 kind='decode' n=100000                         0.30 %       ±9.29% ±12.25% ±15.71%
webstreams/encoding-streams.js len=1024 kind='encode' n=100000                         0.94 %      ±12.63% ±16.64% ±21.35%
webstreams/encoding-streams.js len=16 kind='decode' n=100000                          -0.73 %       ±9.19% ±12.12% ±15.55%
webstreams/encoding-streams.js len=16 kind='encode' n=100000                          -1.13 %       ±8.05% ±10.61% ±13.61%
webstreams/from.js kind='async' n=1000000                                             -1.47 %       ±8.72% ±11.50% ±14.75%
webstreams/from.js kind='sync' n=1000000                                              -0.50 %       ±8.96% ±11.80% ±15.15%
webstreams/js_transfer.js n=10000 payload='ReadableStream'                             1.74 %      ±12.28% ±16.19% ±20.77%
webstreams/js_transfer.js n=10000 payload='TransformStream'                            0.05 %      ±11.91% ±15.70% ±20.15%
webstreams/js_transfer.js n=10000 payload='WritableStream'                             0.38 %      ±11.99% ±15.80% ±20.28%
webstreams/lifecycle.js kind='async-iterator' n=50000                                  0.85 %       ±6.30%  ±8.30% ±10.65%
webstreams/lifecycle.js kind='pipe-through' n=50000                                   -2.21 %       ±6.20%  ±8.17% ±10.49%
webstreams/lifecycle.js kind='pipe-to' n=50000                                        -2.07 %       ±6.28%  ±8.28% ±10.63%
webstreams/lifecycle.js kind='readable' n=50000                                       -0.12 %       ±6.33%  ±8.34% ±10.71%
webstreams/pipe-through.js kind='default' n=500000                                    -0.97 %       ±7.77% ±10.24% ±13.14%
webstreams/pipe-through.js kind='transform' n=500000                                   0.70 %       ±7.79% ±10.27% ±13.18%
webstreams/pipe-to.js highWaterMarkW=1 highWaterMarkR=1 n=500000                      -0.15 %       ±8.33% ±10.97% ±14.08%
webstreams/pipe-to.js highWaterMarkW=1 highWaterMarkR=1024 n=500000                    1.29 %       ±8.20% ±10.81% ±13.87%
webstreams/pipe-to.js highWaterMarkW=1 highWaterMarkR=4096 n=500000                   -0.91 %       ±8.13% ±10.72% ±13.76%
webstreams/pipe-to.js highWaterMarkW=1024 highWaterMarkR=1 n=500000                    0.33 %       ±7.77% ±10.24% ±13.14%
webstreams/pipe-to.js highWaterMarkW=1024 highWaterMarkR=1024 n=500000                 0.99 %       ±7.98% ±10.52% ±13.50%
webstreams/pipe-to.js highWaterMarkW=1024 highWaterMarkR=4096 n=500000                 0.06 %       ±7.90% ±10.42% ±13.36%
webstreams/pipe-to.js highWaterMarkW=4096 highWaterMarkR=1 n=500000                    0.00 %       ±7.75% ±10.22% ±13.11%
webstreams/pipe-to.js highWaterMarkW=4096 highWaterMarkR=1024 n=500000                 1.10 %       ±8.03% ±10.59% ±13.58%
webstreams/pipe-to.js highWaterMarkW=4096 highWaterMarkR=4096 n=500000                -0.34 %       ±7.87% ±10.37% ±13.30%
webstreams/readable-async-iterator.js type='bytes' n=100000                           -0.24 %      ±13.35% ±17.60% ±22.58%
webstreams/readable-async-iterator.js type='normal' n=100000                          -1.13 %       ±6.03%  ±7.95% ±10.21%
webstreams/readable-read-buffered.js bufferSize=1 n=100000                            -0.87 %       ±5.82%  ±7.68%  ±9.85%
webstreams/readable-read-buffered.js bufferSize=10 n=100000                           -0.16 %       ±5.88%  ±7.75%  ±9.94%
webstreams/readable-read-buffered.js bufferSize=100 n=100000                           0.35 %       ±5.71%  ±7.53%  ±9.66%
webstreams/readable-read-buffered.js bufferSize=1000 n=100000                         -0.09 %       ±6.00%  ±7.91% ±10.15%
webstreams/readable-read.js type='byob' n=100000                                *     17.38 %      ±16.09% ±21.21% ±27.22%
webstreams/readable-read.js type='normal' n=100000                                     0.66 %       ±6.35%  ±8.37% ±10.74%
webstreams/tee.js type='bytes' n=100000                                                0.30 %      ±12.69% ±16.72% ±21.46%
webstreams/tee.js type='normal' n=100000                                               0.42 %       ±7.53%  ±9.93% ±12.74%
webstreams/writable-write.js type='await' n=100000                                     1.35 %       ±6.67%  ±8.79% ±11.28%
webstreams/writable-write.js type='queued' n=100000                                    0.68 %       ±8.49% ±11.19% ±14.36%

Be aware that when doing many comparisons the risk of a false-positive
result increases. In this case, there are 46 comparisons, you can thus
expect the following amount of false-positive results:
  2.30 false positives, when considering a   5% risk acceptance (*, **, ***),
  0.46 false positives, when considering a   1% risk acceptance (**, ***),
  0.05 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.

@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 Nov 8, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@mcollina mcollina added the commit-queue PRs queued for automated landing through the Commit Queue. label Nov 9, 2026
@nodejs-github-bot
nodejs-github-bot merged commit 7ae68bd into nodejs:main Nov 9, 2026
89 checks passed
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Landed in 7ae68bd

@nodejs-github-bot nodejs-github-bot removed the commit-queue PRs queued for automated landing through the Commit Queue. label Nov 9, 2026
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