Skip to content

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

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

mcollina wants to merge 2 commits 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.

Constructing a TransformStream allocated five closures for the sink and
source algorithms of its two sides, and each side adopted the start
promise through a wrapper promise plus a thenable job, so a stream
without a start() left about three kilobytes pending in the microtask
queue until the started steps ran. Per-stream creation bursts spend
most of their time copying that graph through the scavenger.

The sink and source algorithms are now shared functions that reach the
transform stream through a field on their controller state (the
controller is passed to the close, abort and cancel algorithms for
that), and the post-start steps of both sides are delivered by one
reaction chain on a shared promise, taking the same microtask hops as
the spec's start promise adoption: three after construction for a
non-thenable start result, two after the adopting promise settles for a
thenable one. The readable and writable transfer state records are
materialized on first transfer instead of per stream.

Signed-off-by: Matteo Collina <hello@matteocollina.com>
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>
@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
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