Skip to content

stream: skip idle webstreams start and watchers - #66424

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

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

Conversation

@mcollina

Copy link
Copy Markdown
Member

Round 21 of the webstreams performance work (follows #66392). It targets what a stream costs before its first chunk and after its last one: construction, tee() and pipeTo() setup and shutdown.

No post-start microtask for sources without pull()

setupReadableStreamDefaultController queued a microtask after every start() to flip the controller's started flag and call pull() if needed. That flag only gates calls into the pull algorithm, so for a source without pull() (push-style sources that enqueue from start(), or new ReadableStream()) the step has nothing observable left to do. The flag is now set synchronously in that case and no closure or task is allocated. Sources with pull() keep the microtask, at the same position. Byte streams get the same treatment.

This also stops a burst of short-lived streams from being kept alive until the next microtask checkpoint, which is where most of the time in the creation benchmark went.

Closed watchers as records instead of promises

pipeTo() holds the only references to its reader and writer, and tee() to its reader, so their [[closedPromise]] records are never observed as promises. Each pipe still materialized both records plus a reaction to watch for close and error, and each tee one record plus a reaction. The watchers are now installed as the records themselves (ClosedPromiseHook), the way the pipe's ready hook already worked: the settle sites resolve or reject the record where they would have settled the promise, and the hook enqueues the watcher at the microtask position its reaction would have had. The erroring and release paths probe the record's promise, so every hook carries one shared forever-pending promise, which setPromiseHandled() skips.

The tee's cancel promise is materialized by the first branch cancel only.

Tests

test/parallel/test-whatwg-readablestream-tee-cancel-settle.js covers the cancel promise settling before and after materialization (cancel one branch then close or error the source, close then cancel both, cancel both) and a tee of an already errored source, for default and byte streams.

A 53-scenario microtask-ordering stress (pull, push, iterators, tee incl. byte tee, pipeTo incl. every shutdown path, transform backpressure, ReadableStream.from, writers) logs identically against main, also with --no-async-context-frame. WPT streams and the webstreams parallel batch are green.

Benchmark

node benchmark/compare.js --runs 20 over benchmark/webstreams:

                                                                       confidence improvement accuracy (*)    (**)   (***)
webstreams/adapters.js kind='readable-from-web' n=100000                              -1.36 %       ±3.06%  ±4.09%  ±5.38%
webstreams/adapters.js kind='readable-to-web' n=100000                                -0.64 %       ±3.22%  ±4.32%  ±5.69%
webstreams/adapters.js kind='writable-from-web' n=100000                              -1.29 %       ±5.43%  ±7.29%  ±9.60%
webstreams/adapters.js kind='writable-to-web' n=100000                                -1.19 %       ±4.96%  ±6.70%  ±8.92%
webstreams/creation.js kind='ReadableStream.tee' n=50000                      ***     20.47 %       ±3.27%  ±4.39%  ±5.79%
webstreams/creation.js kind='ReadableStream' n=50000                          ***    172.40 %      ±14.62% ±19.68% ±26.07%
webstreams/creation.js kind='ReadableStreamBYOBReader' n=50000                ***     15.66 %       ±7.78% ±10.44% ±13.76%
webstreams/creation.js kind='ReadableStreamDefaultReader' n=50000             ***     14.53 %       ±5.73%  ±7.73% ±10.26%
webstreams/creation.js kind='TransformStream' n=50000                                  0.03 %       ±3.21%  ±4.30%  ±5.65%
webstreams/creation.js kind='WritableStream' n=50000                                   4.36 %       ±5.38%  ±7.24%  ±9.60%
webstreams/encoding-streams.js len=1024 kind='decode' n=100000                         3.12 %       ±3.96%  ±5.30%  ±6.97%
webstreams/encoding-streams.js len=1024 kind='encode' n=100000                         0.22 %       ±1.55%  ±2.07%  ±2.73%
webstreams/encoding-streams.js len=16 kind='decode' n=100000                          -1.41 %       ±3.78%  ±5.09%  ±6.75%
webstreams/encoding-streams.js len=16 kind='encode' n=100000                           1.70 %       ±2.27%  ±3.04%  ±4.00%
webstreams/from.js kind='async' n=1000000                                             -0.07 %       ±2.17%  ±2.90%  ±3.82%
webstreams/from.js kind='sync' n=1000000                                              -2.50 %       ±2.77%  ±3.72%  ±4.89%
webstreams/js_transfer.js n=10000 payload='ReadableStream'                            -0.80 %       ±2.08%  ±2.80%  ±3.72%
webstreams/js_transfer.js n=10000 payload='TransformStream'                            0.84 %       ±1.03%  ±1.38%  ±1.82%
webstreams/js_transfer.js n=10000 payload='WritableStream'                      *      1.74 %       ±1.44%  ±1.92%  ±2.53%
webstreams/lifecycle.js kind='async-iterator' n=50000                                  2.02 %       ±5.05%  ±6.76%  ±8.90%
webstreams/lifecycle.js kind='pipe-through' n=50000                                   -1.06 %       ±3.49%  ±4.68%  ±6.17%
webstreams/lifecycle.js kind='pipe-to' n=50000                                         8.60 %      ±10.55% ±14.13% ±18.58%
webstreams/lifecycle.js kind='readable' n=50000                                       -1.55 %       ±3.15%  ±4.22%  ±5.55%
webstreams/pipe-through.js kind='default' n=500000                                     2.93 %       ±3.31%  ±4.44%  ±5.87%
webstreams/pipe-through.js kind='transform' n=500000                                  -1.88 %       ±2.63%  ±3.54%  ±4.70%
webstreams/pipe-to.js highWaterMarkW=1 highWaterMarkR=1 n=500000                       0.58 %       ±3.34%  ±4.48%  ±5.90%
webstreams/pipe-to.js highWaterMarkW=1 highWaterMarkR=1024 n=500000                    1.08 %       ±2.26%  ±3.03%  ±3.99%
webstreams/pipe-to.js highWaterMarkW=1 highWaterMarkR=4096 n=500000                   -1.24 %       ±1.64%  ±2.20%  ±2.90%
webstreams/pipe-to.js highWaterMarkW=1024 highWaterMarkR=1 n=500000                   -0.66 %       ±2.30%  ±3.09%  ±4.08%
webstreams/pipe-to.js highWaterMarkW=1024 highWaterMarkR=1024 n=500000                 0.60 %       ±2.39%  ±3.21%  ±4.23%
webstreams/pipe-to.js highWaterMarkW=1024 highWaterMarkR=4096 n=500000                -0.31 %       ±2.10%  ±2.81%  ±3.70%
webstreams/pipe-to.js highWaterMarkW=4096 highWaterMarkR=1 n=500000                   -0.67 %       ±2.09%  ±2.80%  ±3.68%
webstreams/pipe-to.js highWaterMarkW=4096 highWaterMarkR=1024 n=500000                -0.77 %       ±2.80%  ±3.76%  ±4.96%
webstreams/pipe-to.js highWaterMarkW=4096 highWaterMarkR=4096 n=500000                -1.60 %       ±2.47%  ±3.31%  ±4.37%
webstreams/readable-async-iterator.js type='bytes' n=100000                            1.17 %       ±2.53%  ±3.39%  ±4.46%
webstreams/readable-async-iterator.js type='normal' n=100000                           1.62 %       ±5.17%  ±6.94%  ±9.16%
webstreams/readable-read-buffered.js bufferSize=1 n=100000                             0.09 %       ±5.16%  ±6.92%  ±9.13%
webstreams/readable-read-buffered.js bufferSize=10 n=100000                            2.98 %       ±4.40%  ±5.90%  ±7.79%
webstreams/readable-read-buffered.js bufferSize=100 n=100000                          -1.04 %       ±4.42%  ±5.93%  ±7.80%
webstreams/readable-read-buffered.js bufferSize=1000 n=100000                          6.55 %       ±7.43%  ±9.97% ±13.15%
webstreams/readable-read.js type='byob' n=100000                                       0.18 %       ±1.34%  ±1.79%  ±2.35%
webstreams/readable-read.js type='normal' n=100000                                    -1.02 %       ±4.63%  ±6.21%  ±8.17%
webstreams/tee.js type='bytes' n=100000                                               -0.89 %       ±2.04%  ±2.73%  ±3.61%
webstreams/tee.js type='normal' n=100000                                               0.20 %       ±2.22%  ±2.97%  ±3.91%
webstreams/writable-write.js type='await' n=100000                                     2.10 %       ±2.82%  ±3.80%  ±5.03%
webstreams/writable-write.js type='queued' n=100000                                    1.90 %       ±2.45%  ±3.31%  ±4.41%

The lifecycle.js rows re-run with 40 samples, since the pipe-to row has a wide interval:

                                                      confidence improvement accuracy (*)   (**)   (***)
webstreams/lifecycle.js kind='async-iterator' n=50000                 0.77 %       ±3.42% ±4.54%  ±5.88%
webstreams/lifecycle.js kind='pipe-through' n=50000                   1.56 %       ±2.91% ±3.86%  ±5.00%
webstreams/lifecycle.js kind='pipe-to' n=50000                **     10.98 %       ±7.03% ±9.33% ±12.09%
webstreams/lifecycle.js kind='readable' n=50000                      -0.67 %       ±2.13% ±2.83%  ±3.67%

The long-running rows (pipe-to, pipe-through, tee, read, from) are flat as expected: the savings are per stream, per pipe and per tee, not per chunk. A local harness that creates and drains a 4-chunk push-style stream measured +22 %, and a 4-chunk pipe +10 %.


AI generated, humanly reviewed.

ReadableStream.prototype.values() built each iterator from an object
literal with a computed symbol-key method plus five closures. Such a
literal is rebuilt through the runtime on every evaluation, costing
close to a microsecond per iterator, which dominates iterating a
short-lived stream.

Move next() and return() to a shared ReadableStreamAsyncIterator
prototype, as for any WebIDL async iterator, and keep the per-iterator
state in its read request. The prototype chain and property shape are
the ones WPT checks; the placeholder AsyncIterator object in util.js is
no longer needed. As in WebIDL, next() and return() now reject when
called on something that is not a ReadableStream async iterator, and
iterators no longer carry own next/return properties.

Add an async-iterator kind to benchmark/webstreams/lifecycle.js.

webstreams/lifecycle.js kind='async-iterator'   ***  +16.22%

Signed-off-by: Matteo Collina <hello@matteocollina.com>
A source without pull() has nothing observable left to do in its
post-start step, since the started flag only gates calls into the pull
algorithm. Set the flag right away instead of from a microtask, so a
push-style ReadableStream allocates neither the closure nor the task
and is not kept alive until the next microtask checkpoint.

pipeTo and tee hold the only references to their reader and writer, so
their [[closedPromise]] records are never observed as promises. Install
the watchers as the records themselves, as pipeTo's ready hook already
does, instead of materializing a promise plus reaction per side, and
hand the erroring/release probes one shared pending promise. The tee's
cancel promise is likewise materialized by the first branch cancel.

Microtask ordering is unchanged: each hook enqueues its watcher at the
position the promise reaction would have had.

node benchmark/compare.js --runs 20 over benchmark/webstreams (46 rows,
all others within the confidence interval):

webstreams/creation.js kind='ReadableStream'               ***  +172.40%
webstreams/creation.js kind='ReadableStream.tee'           ***   +20.47%
webstreams/creation.js kind='ReadableStreamBYOBReader'     ***   +15.66%
webstreams/creation.js kind='ReadableStreamDefaultReader'  ***   +14.53%
webstreams/lifecycle.js kind='pipe-to' (40 runs)           **    +10.98%

Signed-off-by: Matteo Collina <hello@matteocollina.com>
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Review requested:

  • @nodejs/performance

@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 30, 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