Skip to content

stream: reduce allocations on WHATWG streams hot paths#63876

Open
mcollina wants to merge 1 commit into
nodejs:mainfrom
mcollina:webstream-js-perf-squashed
Open

stream: reduce allocations on WHATWG streams hot paths#63876
mcollina wants to merge 1 commit 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

Pure-JavaScript optimizations to lib/internal/webstreams/* that remove
per-chunk and per-construction allocations without any observable
behavior change.

Per-chunk:
- Reuse the pull and write promise-reaction closures per controller,
  created lazily on the first pull/write, instead of allocating two
  fresh closures per chunk.
- Add the buffered fast path to the ReadableStream async iterator,
  mirroring ReadableStreamDefaultReader.read(): when data is queued in
  a default controller, dequeue directly and skip the read request,
  the deferred promise, and the promise chaining on the next call.
- Specialize the user-algorithm promise-callback wrappers by arity
  (0/1/2), replacing the rest-parameter + ReflectApply form that
  allocated an arguments array per invocation.
- Share immutable nil records for the writable closeRequest,
  inFlightWriteRequest, inFlightCloseRequest and pendingAbortRequest
  resets; push the PromiseWithResolvers() record directly as the write
  request instead of rebuilding it.

Per-construction:
- Run the post-start step through queueMicrotask() when the start
  algorithm result is not an object, instead of wrapping it in
  new Promise((r) => r(result)) plus a two-closure reaction. Object
  and thenable results keep the promise path (adoption timing and
  .then lookups are observable).
- Materialize the reader/writer .closed and writer .ready records, and
  the stream-level closed-promise interop record, lazily on first
  observation. Their settlement is fully derivable from the
  stream/writer state, so construction allocates no promise for them
  and the settle sites only touch a record once it exists. Identity
  follows the spec: in-place rejections keep the cached record, the
  replace cases drop the cache so the next observation derives the new
  promise.
- Remove dead allocations: the never-read close record in the writable
  stream state, the placeholder reader/writer records and duplicate
  read-request arrays that setup replaced, the per-stream () => 1 size
  algorithms, and the kControllerErrorFunction placeholder plus bound
  function (now a prototype method; byte streams keep their historical
  no-op there).

The benchmark/webstreams/creation.js benchmark is updated to collect
garbage before each timed window: at the default n=50000 the window is
only ~20-40ms, short enough that garbage from the setup phase is
collected inside it and dominates the result (the
ReadableStreamBYOBReader row swung from -52% to +13% on the same
change purely by varying n).

Benchmark results vs main, same-day builds, benchmark/compare.js
--runs 10:

  creation ReadableStreamDefaultReader (n=500k)  +164% ***
  creation ReadableStreamBYOBReader (n=500k)     +152% ***
  creation WritableStream (n=500k)                +97% ***
  creation ReadableStream.tee (n=500k)            +61% ***
  creation TransformStream (n=500k)               +59% ***
  creation ReadableStream (n=500k)                +50% ***
  readable-async-iterator                         +35% ***
  pipe-to (16 hwm configs)                  +4.8..+9.3% ***
  js_transfer WS / RS / TS              +8.0% / +7.9% / +6.5% ***
  readable-read-buffered bs=1                     +8.7% ***
  readable-read normal / byob                  +8.0% / parity

WPT streams/compression/encoding results are identical to main
(1403/338/3822 subtests passed, same 8 expected failures by name) and
all webstreams-related parallel tests pass.

Assisted-by: Claude Fable 5 <noreply@anthropic.com>
@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
// 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.

@mcollina mcollina force-pushed the webstream-js-perf-squashed branch from 6d140aa to 92194f3 Compare June 13, 2026 09:22
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.

5 participants