Skip to content

stream: reduce allocations on WHATWG streams hot paths#63876

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

stream: reduce allocations on WHATWG streams hot paths#63876
mcollina wants to merge 2 commits into
nodejs:mainfrom
mcollina:webstream-js-perf-squashed

Conversation

@mcollina

@mcollina mcollina commented Jun 12, 2026

Copy link
Copy Markdown
Member

Pure-JavaScript allocation reductions on the WHATWG streams hot paths partially based on the findings of #63872 : reused promise-reaction closures per controller (pull/write), a buffered fast path in the async iterator, queueMicrotask() for non-thenable start results, arity-specialized algorithm wrappers, shared nil state records, and removal of several dead per-instance allocations. No observable behavior change: WPT streams/compression/encoding results are identical to main (same subtests passing, same 8 expected failures by name).

Benchmark results (benchmark/compare.js --runs 10, both binaries built the same day from the same toolchain):

                                                                       confidence improvement accuracy (*)    (**)    (***)
webstreams/creation.js kind='ReadableStream.tee' n=50000                      ***     29.74 %       ±4.57%  ±6.28%   ±8.58%
webstreams/creation.js kind='ReadableStream' n=50000                          ***     21.46 %       ±6.77%  ±9.51%  ±13.48%
webstreams/creation.js kind='ReadableStreamBYOBReader' n=50000                ***    -51.78 %       ±3.80%  ±5.21%   ±7.10%
webstreams/creation.js kind='ReadableStreamDefaultReader' n=50000              **    102.49 %      ±62.86% ±90.21% ±132.47%
webstreams/creation.js kind='TransformStream' n=50000                         ***     33.00 %       ±6.62%  ±9.22%  ±12.90%
webstreams/creation.js kind='WritableStream' n=50000                          ***    102.28 %      ±13.80% ±19.67%  ±28.56%
webstreams/js_transfer.js n=10000 payload='ReadableStream'                     **      2.97 %       ±1.97%  ±2.74%   ±3.82%
webstreams/js_transfer.js n=10000 payload='TransformStream'                   ***      6.29 %       ±1.97%  ±2.75%   ±3.87%
webstreams/js_transfer.js n=10000 payload='WritableStream'                    ***      7.66 %       ±1.58%  ±2.19%   ±3.01%
webstreams/pipe-to.js highWaterMarkW=1024 highWaterMarkR=1024 n=500000                 1.27 %       ±2.50%  ±3.48%   ±4.85%
webstreams/pipe-to.js highWaterMarkW=1024 highWaterMarkR=2048 n=500000          *      2.61 %       ±2.34%  ±3.22%   ±4.40%
webstreams/pipe-to.js highWaterMarkW=1024 highWaterMarkR=4096 n=500000         **      3.47 %       ±1.93%  ±2.70%   ±3.77%
webstreams/pipe-to.js highWaterMarkW=1024 highWaterMarkR=512 n=500000         ***      3.13 %       ±1.61%  ±2.21%   ±3.03%
webstreams/pipe-to.js highWaterMarkW=2048 highWaterMarkR=1024 n=500000        ***      4.46 %       ±2.23%  ±3.05%   ±4.16%
webstreams/pipe-to.js highWaterMarkW=2048 highWaterMarkR=2048 n=500000        ***      3.71 %       ±1.89%  ±2.60%   ±3.54%
webstreams/pipe-to.js highWaterMarkW=2048 highWaterMarkR=4096 n=500000        ***      4.33 %       ±1.65%  ±2.27%   ±3.09%
webstreams/pipe-to.js highWaterMarkW=2048 highWaterMarkR=512 n=500000         ***      3.80 %       ±1.81%  ±2.48%   ±3.38%
webstreams/pipe-to.js highWaterMarkW=4096 highWaterMarkR=1024 n=500000                 1.20 %       ±1.53%  ±2.10%   ±2.88%
webstreams/pipe-to.js highWaterMarkW=4096 highWaterMarkR=2048 n=500000         **      4.01 %       ±2.18%  ±3.01%   ±4.15%
webstreams/pipe-to.js highWaterMarkW=4096 highWaterMarkR=4096 n=500000        ***      4.17 %       ±2.16%  ±2.99%   ±4.15%
webstreams/pipe-to.js highWaterMarkW=4096 highWaterMarkR=512 n=500000         ***      3.34 %       ±1.51%  ±2.10%   ±2.92%
webstreams/pipe-to.js highWaterMarkW=512 highWaterMarkR=1024 n=500000         ***      2.91 %       ±1.39%  ±1.93%   ±2.67%
webstreams/pipe-to.js highWaterMarkW=512 highWaterMarkR=2048 n=500000         ***      3.50 %       ±1.86%  ±2.56%   ±3.49%
webstreams/pipe-to.js highWaterMarkW=512 highWaterMarkR=4096 n=500000           *      2.82 %       ±2.25%  ±3.13%   ±4.36%
webstreams/pipe-to.js highWaterMarkW=512 highWaterMarkR=512 n=500000          ***      4.30 %       ±1.45%  ±2.00%   ±2.74%
webstreams/readable-async-iterator.js n=100000                                ***     38.07 %       ±5.69%  ±7.82%  ±10.69%
webstreams/readable-read-buffered.js bufferSize=1 n=100000                             2.94 %       ±6.05%  ±8.29%  ±11.30%
webstreams/readable-read-buffered.js bufferSize=10 n=100000                            2.50 %       ±7.03%  ±9.76%  ±13.61%
webstreams/readable-read-buffered.js bufferSize=100 n=100000                          -2.42 %       ±6.57%  ±9.01%  ±12.29%
webstreams/readable-read-buffered.js bufferSize=1000 n=100000                         -3.28 %       ±6.07%  ±8.42%  ±11.68%
webstreams/readable-read.js type='byob' n=100000                                      -0.25 %       ±1.73%  ±2.41%   ±3.36%
webstreams/readable-read.js type='normal' n=100000                                     4.12 %       ±6.84%  ±9.41%  ±12.91%

The creation.js rows at the stock n=50000 measure a 20-40ms window and are unreliable; re-run at --set n=500000:

                                                                   confidence improvement accuracy (*)   (**)   (***)
webstreams/creation.js kind='ReadableStream.tee' n=500000                          2.31 %       ±2.49% ±3.50%  ±4.95%
webstreams/creation.js kind='ReadableStream' n=500000                     ***     13.87 %       ±2.38% ±3.26%  ±4.46%
webstreams/creation.js kind='ReadableStreamBYOBReader' n=500000           ***     12.98 %       ±4.95% ±6.82%  ±9.40%
webstreams/creation.js kind='ReadableStreamDefaultReader' n=500000         **      9.82 %       ±6.90% ±9.61% ±13.42%
webstreams/creation.js kind='TransformStream' n=500000                    ***     50.30 %       ±2.23% ±3.07%  ±4.19%
webstreams/creation.js kind='WritableStream' n=500000                     ***     97.09 %       ±6.55% ±9.18% ±12.95%

@nodejs-github-bot nodejs-github-bot added needs-ci PRs that need a full CI run. web streams labels Jun 12, 2026
@mcollina mcollina requested review from MattiasBuelens, aduh95 and jasnell and removed request for aduh95 June 12, 2026 14:04

@MattiasBuelens MattiasBuelens left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks pretty sensible to me! 👍

Comment on lines +552 to +557
// No read is in flight. Mirror the buffered fast path of
// ReadableStreamDefaultReader.read(): when data is already queued
// in a default controller, resolve immediately without allocating
// a read request. The result settles synchronously, so leaving
// state.current undefined matches the state the slow path reaches
// once its read request callbacks have settled.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we always have to inline this... 🤔

I ported some of your previous optimizations to web-streams-polyfill, but instead of copying the code around I made defaultReader.read() create a different kind of ReadRequest if it knows that it will be resolved synchronously.

Perhaps we can do the same here, and have nextSteps create a different kind of AsyncIteratorReadRequest if it can be resolved synchronously? (I forgot to do that in my polyfill, it seems. 😅 ) Or would that risk turning readableStreamDefaultReaderRead megamorphic? (Or maybe it already is?)

Comment thread lib/internal/webstreams/readablestream.js
Comment on lines +2608 to +2613
queueMicrotask(() => {
controller[kState].started = true;
assert(!controller[kState].pulling);
assert(!controller[kState].pullAgain);
readableStreamDefaultControllerCallPullIfNeeded(controller);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit-pick: maybe pull this callback into a const, so we can reuse it for the PromisePrototypeThen below?

@mcollina mcollina added the request-ci Add this label to start a Jenkins CI on a PR. label Jun 13, 2026
@github-actions github-actions Bot removed the request-ci Add this label to start a Jenkins CI on a PR. label Jun 13, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@mcollina

mcollina commented Jun 13, 2026

Copy link
Copy Markdown
Member Author

Updated benchmarks after the regression fix:

                                                                       confidence improvement accuracy (*)    (**)   (***)
webstreams/creation.js kind='ReadableStream.tee' n=50000                      ***     22.39 %       ±3.84%  ±5.29%  ±7.28%
webstreams/creation.js kind='ReadableStream' n=50000                          ***     41.12 %       ±4.41%  ±6.05%  ±8.27%
webstreams/creation.js kind='ReadableStreamBYOBReader' n=50000                ***     53.34 %       ±7.92% ±11.05% ±15.48%
webstreams/creation.js kind='ReadableStreamDefaultReader' n=50000             ***     68.90 %       ±9.00% ±12.58% ±17.70%
webstreams/creation.js kind='TransformStream' n=50000                         ***     31.64 %       ±4.60%  ±6.39%  ±8.91%
webstreams/creation.js kind='WritableStream' n=50000                          ***     88.35 %       ±6.68%  ±9.15% ±12.47%
webstreams/js_transfer.js n=10000 payload='ReadableStream'                    ***      6.16 %       ±1.95%  ±2.67%  ±3.65%
webstreams/js_transfer.js n=10000 payload='TransformStream'                   ***      5.74 %       ±1.37%  ±1.89%  ±2.61%
webstreams/js_transfer.js n=10000 payload='WritableStream'                    ***      9.38 %       ±1.87%  ±2.58%  ±3.54%
webstreams/pipe-to.js highWaterMarkW=1024 highWaterMarkR=1024 n=500000        ***      4.16 %       ±1.81%  ±2.48%  ±3.38%
webstreams/pipe-to.js highWaterMarkW=1024 highWaterMarkR=2048 n=500000        ***      8.13 %       ±2.31%  ±3.21%  ±4.48%
webstreams/pipe-to.js highWaterMarkW=1024 highWaterMarkR=4096 n=500000        ***      5.68 %       ±1.63%  ±2.26%  ±3.15%
webstreams/pipe-to.js highWaterMarkW=1024 highWaterMarkR=512 n=500000         ***      4.67 %       ±1.91%  ±2.63%  ±3.60%
webstreams/pipe-to.js highWaterMarkW=2048 highWaterMarkR=1024 n=500000        ***      5.06 %       ±1.84%  ±2.55%  ±3.55%
webstreams/pipe-to.js highWaterMarkW=2048 highWaterMarkR=2048 n=500000        ***      5.78 %       ±2.42%  ±3.31%  ±4.52%
webstreams/pipe-to.js highWaterMarkW=2048 highWaterMarkR=4096 n=500000        ***      6.86 %       ±1.53%  ±2.09%  ±2.85%
webstreams/pipe-to.js highWaterMarkW=2048 highWaterMarkR=512 n=500000         ***      4.90 %       ±1.46%  ±2.00%  ±2.73%
webstreams/pipe-to.js highWaterMarkW=4096 highWaterMarkR=1024 n=500000        ***      7.43 %       ±2.10%  ±2.88%  ±3.93%
webstreams/pipe-to.js highWaterMarkW=4096 highWaterMarkR=2048 n=500000         **      4.37 %       ±2.42%  ±3.36%  ±4.68%
webstreams/pipe-to.js highWaterMarkW=4096 highWaterMarkR=4096 n=500000        ***      7.44 %       ±2.05%  ±2.83%  ±3.90%
webstreams/pipe-to.js highWaterMarkW=4096 highWaterMarkR=512 n=500000         ***      4.21 %       ±1.92%  ±2.66%  ±3.70%
webstreams/pipe-to.js highWaterMarkW=512 highWaterMarkR=1024 n=500000         ***      3.29 %       ±1.47%  ±2.02%  ±2.75%
webstreams/pipe-to.js highWaterMarkW=512 highWaterMarkR=2048 n=500000         ***      6.69 %       ±2.94%  ±4.07%  ±5.63%
webstreams/pipe-to.js highWaterMarkW=512 highWaterMarkR=4096 n=500000         ***      7.74 %       ±1.76%  ±2.42%  ±3.33%
webstreams/pipe-to.js highWaterMarkW=512 highWaterMarkR=512 n=500000          ***      5.90 %       ±1.89%  ±2.60%  ±3.56%
webstreams/readable-async-iterator.js n=100000                                ***     36.65 %       ±6.43%  ±8.85% ±12.15%
webstreams/readable-read-buffered.js bufferSize=1 n=100000                     **      7.69 %       ±5.21%  ±7.15%  ±9.76%
webstreams/readable-read-buffered.js bufferSize=10 n=100000                    **      6.47 %       ±4.28%  ±5.86%  ±7.99%
webstreams/readable-read-buffered.js bufferSize=100 n=100000                          -1.00 %       ±4.53%  ±6.27%  ±8.67%
webstreams/readable-read-buffered.js bufferSize=1000 n=100000                          2.40 %       ±5.92%  ±8.11% ±11.07%
webstreams/readable-read.js type='byob' n=100000                                       0.08 %       ±2.07%  ±2.89%  ±4.07%
webstreams/readable-read.js type='normal' n=100000                                     3.62 %       ±7.05%  ±9.71% ±13.34%
                                                                 confidence improvement accuracy (*)    (**)   (***)
webstreams/creation.js kind='ReadableStream.tee' n=500000                 ***     60.86 %       ±2.08%  ±2.93%  ±4.15%
webstreams/creation.js kind='ReadableStream' n=500000                     ***     47.16 %       ±3.58%  ±4.93%  ±6.76%
webstreams/creation.js kind='ReadableStreamBYOBReader' n=500000           ***    157.82 %       ±8.38% ±11.95% ±17.35%
webstreams/creation.js kind='ReadableStreamDefaultReader' n=500000        ***    164.85 %       ±8.89% ±12.76% ±18.71%
webstreams/creation.js kind='TransformStream' n=500000                    ***     60.98 %       ±1.84%  ±2.52%  ±3.45%
webstreams/creation.js kind='WritableStream' n=500000                     ***     91.19 %       ±3.71%  ±5.23%  ±7.46%

@mcollina mcollina marked this pull request as ready for review June 13, 2026 07:51
Comment thread lib/internal/webstreams/util.js Outdated
// each known call-site arity gets its own wrapper. The exact number of
// arguments passed through to the user callback is observable and must be
// preserved.
function createPromiseCallback0(name, fn, thisArg) {

@aduh95 aduh95 Jun 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we give them more readable names? e.g. createPromiseCallbackNoParams, createPromiseCallback1Param, createPromiseCallback2Params

It would probably makes sense to split this into its own PR

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would actually prefer not to. This is relatively small, that fighting CI only once would save a significant amount of time.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've opened #63909. The time you try to save will be tech debts for backporters, I'd much rather pay it now

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kinda prefer the better names now also

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm ok with changing the names. I don't think doing that change without the rest of this PR is useful.

@mcollina mcollina force-pushed the webstream-js-perf-squashed branch from 6d140aa to 92194f3 Compare June 13, 2026 09:22
Pure-JavaScript optimizations to lib/internal/webstreams/* that reduce
per-chunk and per-construction allocations on hot paths without
observable behavior change.

Per-chunk: reuse promise-reaction closures per controller, add buffered
fast path for async iterator, specialize callback wrappers by arity,
and share immutable nil records for writable stream resets.

Per-construction: use queueMicrotask for non-object start results,
materialize reader/writer .closed and .ready records lazily, and remove
dead allocations.

Assisted-by: Claude Fable 5
Signed-off-by: Matteo Collina <hello@matteocollina.com>
@mcollina mcollina force-pushed the webstream-js-perf-squashed branch from 92194f3 to da5b0ee Compare June 14, 2026 12:16
@mcollina mcollina added the request-ci Add this label to start a Jenkins CI on a PR. label Jun 14, 2026
@github-actions github-actions Bot removed the request-ci Add this label to start a Jenkins CI on a PR. label Jun 14, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Comment on lines +178 to +184
// Arity-specialized variants of the promise-callback wrapper. The generic
// rest-parameter + ReflectApply form allocated an arguments array on every
// invocation; these run on per-chunk hot paths (pull/write/transform), so
// each known call-site arity gets its own wrapper. The exact number of
// arguments passed through to the user callback is observable and must be
// preserved.
function createPromiseCallbackNoParams(name, fn, thisArg) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that it seems to not have a significant effect on the benchmark, consider reverting that so this PR doesn't conflict with #63909

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does after these other optimizations are applied.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll rebase this after that land

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants