Skip to content

stream: cut per-chunk allocations in pipeTo - #64890

Merged
nodejs-github-bot merged 1 commit into
nodejs:mainfrom
mcollina:webstream-perf-round11
Aug 4, 2026
Merged

stream: cut per-chunk allocations in pipeTo#64890
nodejs-github-bot merged 1 commit into
nodejs:mainfrom
mcollina:webstream-perf-round11

Conversation

@mcollina

@mcollina mcollina commented Aug 1, 2026

Copy link
Copy Markdown
Member

readableStreamPipeTo allocated, for every chunk written to the destination, a { promise, resolve, reject } write request record that it immediately marked as handled, and drove its loop with an async step()/run() pair whose implicit promises cost one allocation and one reaction per iteration. The parked-read path additionally allocated a read request object, a PromiseWithResolvers record, and a microtask closure per chunk; this is the steady state for pipeThrough, since a TransformStream's readable side has a high water mark of zero.

This PR replaces the per-write records with a single per-pipe tracker that the write request queue holds once per pending write and whose resolve()/reject() methods maintain a pending-write count, drives the pump loop with plain callbacks instead of async functions, and reuses one read request and one forwarding function across all chunks — the same pattern tee uses since c543cfb.

Semantics preserved: shutdown still waits for all pending writes before finalizing (the tracker arms a stall promise only during shutdown), a write that cannot proceed latches its error exactly like the old rejected-and-marked-handled currentWrite did, and the spec-required microtask before writing a parked chunk is kept.

Benchmark results

benchmark/compare.js --runs 20 vs current main:

webstreams/pipe-to.js highWaterMarkW=1024 highWaterMarkR=1024 n=500000        ***     31.07 %       ±1.66%
webstreams/pipe-to.js highWaterMarkW=1024 highWaterMarkR=2048 n=500000        ***     31.60 %       ±1.73%
webstreams/pipe-to.js highWaterMarkW=1024 highWaterMarkR=4096 n=500000        ***     35.79 %       ±1.96%
webstreams/pipe-to.js highWaterMarkW=1024 highWaterMarkR=512 n=500000         ***     32.89 %       ±2.30%
webstreams/pipe-to.js highWaterMarkW=2048 highWaterMarkR=1024 n=500000        ***     29.85 %       ±1.94%
webstreams/pipe-to.js highWaterMarkW=2048 highWaterMarkR=2048 n=500000        ***     32.93 %       ±1.91%
webstreams/pipe-to.js highWaterMarkW=2048 highWaterMarkR=4096 n=500000        ***     34.23 %       ±2.55%
webstreams/pipe-to.js highWaterMarkW=2048 highWaterMarkR=512 n=500000         ***     32.64 %       ±2.25%
webstreams/pipe-to.js highWaterMarkW=4096 highWaterMarkR=1024 n=500000        ***     34.72 %       ±2.41%
webstreams/pipe-to.js highWaterMarkW=4096 highWaterMarkR=2048 n=500000        ***     32.16 %       ±1.65%
webstreams/pipe-to.js highWaterMarkW=4096 highWaterMarkR=4096 n=500000        ***     33.59 %       ±1.92%
webstreams/pipe-to.js highWaterMarkW=4096 highWaterMarkR=512 n=500000         ***     32.56 %       ±1.69%
webstreams/pipe-to.js highWaterMarkW=512 highWaterMarkR=1024 n=500000         ***     34.19 %       ±1.79%
webstreams/pipe-to.js highWaterMarkW=512 highWaterMarkR=2048 n=500000         ***     33.93 %       ±2.12%
webstreams/pipe-to.js highWaterMarkW=512 highWaterMarkR=4096 n=500000         ***     33.07 %       ±2.18%
webstreams/pipe-to.js highWaterMarkW=512 highWaterMarkR=512 n=500000          ***     33.35 %       ±1.97%

A spot pipeThrough(new TransformStream()) passthrough loop (500k chunks) improves ~17% (0.41 → 0.49 M chunks/s), since the outer pipe of a passthrough runs the parked-read path for every chunk. All other webstreams benchmarks are unchanged (verified with a 20-run recheck of the two rows that initially flagged, both phantom).

Gates: WPT streams/compression/encoding, the full parallel whatwg/webstream suites, and the blob/fetch/filehandle/duplex adapter tests all pass.

@nodejs-github-bot nodejs-github-bot added needs-ci PRs that need a full CI run. web streams labels Aug 1, 2026
@codecov

codecov Bot commented Aug 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.97297% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.29%. Comparing base (276fe2b) to head (d838534).
⚠️ Report is 57 commits behind head on main.

Files with missing lines Patch % Lines
lib/internal/webstreams/readablestream.js 96.73% 3 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff            @@
##             main   #64890    +/-   ##
========================================
  Coverage   90.28%   90.29%            
========================================
  Files         760      760            
  Lines      247076   247220   +144     
  Branches    46594    46618    +24     
========================================
+ Hits       223081   223224   +143     
  Misses      15458    15458            
- Partials     8537     8538     +1     
Files with missing lines Coverage Δ
lib/internal/webstreams/writablestream.js 99.51% <100.00%> (+0.01%) ⬆️
lib/internal/webstreams/readablestream.js 98.16% <96.73%> (-0.04%) ⬇️

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

@mcollina
mcollina force-pushed the webstream-perf-round11 branch from c084df5 to 40a4e44 Compare August 1, 2026 06:51
readableStreamPipeTo allocated, for every chunk written to the
destination, a { promise, resolve, reject } write request record that
it immediately marked as handled, and drove its loop with an async
step()/run() pair whose implicit promises cost one allocation and one
reaction per iteration. The parked-read path additionally allocated a
read request object, a PromiseWithResolvers record, and a microtask
closure per chunk; this is the steady state for pipeThrough, since a
TransformStream's readable side has a high water mark of zero.

Replace the per-write records with a single per-pipe tracker that the
write request queue holds once per pending write and whose
resolve()/reject() methods maintain a pending-write count, drive the
pump loop with plain callbacks instead of async functions, and reuse
one read request and one forwarding function across all chunks, the
same pattern tee uses since c543cfb.

Benchmark results (benchmark/compare.js --runs 20):
webstreams/pipe-to.js +29.9% to +35.8% across all 16 configurations
(all 99.9% confidence); a pipeThrough(TransformStream) passthrough
loop improves ~17%; every other webstreams benchmark is unchanged.

Signed-off-by: Matteo Collina <hello@matteocollina.com>
@mcollina
mcollina force-pushed the webstream-perf-round11 branch from 40a4e44 to d838534 Compare August 2, 2026 07:30
@mcollina
mcollina requested a review from anonrig August 3, 2026 13:46
@mcollina mcollina added the request-ci Add this label to start a Jenkins CI on a PR. label Aug 3, 2026
@github-actions github-actions Bot removed the request-ci Add this label to start a Jenkins CI on a PR. label Aug 3, 2026
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

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

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/creation.js kind='ReadableStream.tee' n=50000                               0.17 %       ±5.76%  ±7.59%  ±9.74%
webstreams/creation.js kind='ReadableStream' n=50000                                   0.85 %       ±6.71%  ±8.85% ±11.36%
webstreams/creation.js kind='ReadableStreamBYOBReader' n=50000                        -1.78 %       ±7.75% ±10.21% ±13.11%
webstreams/creation.js kind='ReadableStreamDefaultReader' n=50000                      1.39 %       ±8.70% ±11.47% ±14.72%
webstreams/creation.js kind='TransformStream' n=50000                                 -1.73 %       ±5.85%  ±7.71%  ±9.90%
webstreams/creation.js kind='WritableStream' n=50000                                   1.13 %       ±6.31%  ±8.32% ±10.67%
webstreams/js_transfer.js n=10000 payload='ReadableStream'                             0.30 %      ±10.48% ±13.81% ±17.72%
webstreams/js_transfer.js n=10000 payload='TransformStream'                           -1.58 %       ±8.89% ±11.72% ±15.04%
webstreams/js_transfer.js n=10000 payload='WritableStream'                            -1.21 %       ±9.74% ±12.84% ±16.47%
webstreams/pipe-to.js highWaterMarkW=1024 highWaterMarkR=1024 n=500000        ***     33.18 %       ±9.30% ±12.26% ±15.73%
webstreams/pipe-to.js highWaterMarkW=1024 highWaterMarkR=2048 n=500000        ***     33.39 %       ±9.22% ±12.16% ±15.61%
webstreams/pipe-to.js highWaterMarkW=1024 highWaterMarkR=4096 n=500000        ***     32.92 %       ±9.17% ±12.09% ±15.52%
webstreams/pipe-to.js highWaterMarkW=1024 highWaterMarkR=512 n=500000         ***     31.66 %       ±9.10% ±12.00% ±15.40%
webstreams/pipe-to.js highWaterMarkW=2048 highWaterMarkR=1024 n=500000        ***     32.71 %       ±9.16% ±12.07% ±15.49%
webstreams/pipe-to.js highWaterMarkW=2048 highWaterMarkR=2048 n=500000        ***     32.74 %       ±9.18% ±12.10% ±15.53%
webstreams/pipe-to.js highWaterMarkW=2048 highWaterMarkR=4096 n=500000        ***     32.53 %       ±9.43% ±12.43% ±15.95%
webstreams/pipe-to.js highWaterMarkW=2048 highWaterMarkR=512 n=500000         ***     31.97 %       ±9.20% ±12.12% ±15.56%
webstreams/pipe-to.js highWaterMarkW=4096 highWaterMarkR=1024 n=500000        ***     30.93 %       ±9.39% ±12.38% ±15.89%
webstreams/pipe-to.js highWaterMarkW=4096 highWaterMarkR=2048 n=500000        ***     33.39 %       ±9.22% ±12.16% ±15.61%
webstreams/pipe-to.js highWaterMarkW=4096 highWaterMarkR=4096 n=500000        ***     31.64 %       ±9.31% ±12.28% ±15.76%
webstreams/pipe-to.js highWaterMarkW=4096 highWaterMarkR=512 n=500000         ***     32.06 %       ±9.12% ±12.02% ±15.43%
webstreams/pipe-to.js highWaterMarkW=512 highWaterMarkR=1024 n=500000         ***     32.82 %       ±9.06% ±11.94% ±15.33%
webstreams/pipe-to.js highWaterMarkW=512 highWaterMarkR=2048 n=500000         ***     32.94 %       ±9.27% ±12.22% ±15.68%
webstreams/pipe-to.js highWaterMarkW=512 highWaterMarkR=4096 n=500000         ***     32.55 %       ±8.87% ±11.69% ±15.00%
webstreams/pipe-to.js highWaterMarkW=512 highWaterMarkR=512 n=500000          ***     32.10 %       ±9.29% ±12.25% ±15.72%
webstreams/readable-async-iterator.js type='bytes' n=100000                           -1.14 %       ±7.92% ±10.44% ±13.40%
webstreams/readable-async-iterator.js type='normal' n=100000                           0.91 %       ±6.86%  ±9.04% ±11.60%
webstreams/readable-read-buffered.js bufferSize=1 n=100000                             0.67 %       ±8.01% ±10.56% ±13.55%
webstreams/readable-read-buffered.js bufferSize=10 n=100000                           -4.02 %       ±6.93%  ±9.13% ±11.72%
webstreams/readable-read-buffered.js bufferSize=100 n=100000                          -0.46 %       ±7.73% ±10.19% ±13.07%
webstreams/readable-read-buffered.js bufferSize=1000 n=100000                          3.04 %       ±7.43%  ±9.79% ±12.56%
webstreams/readable-read.js type='byob' n=100000                                       0.01 %       ±9.43% ±12.43% ±15.94%
webstreams/readable-read.js type='normal' n=100000                                    -0.00 %       ±7.62% ±10.05% ±12.89%
webstreams/tee.js type='bytes' n=100000                                               -1.36 %      ±10.82% ±14.26% ±18.29%
webstreams/tee.js type='normal' n=100000                                               1.55 %       ±8.88% ±11.70% ±15.02%

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

@mcollina mcollina added the commit-queue Add this label to land a pull request using GitHub Actions. label Aug 4, 2026
@nodejs-github-bot nodejs-github-bot removed the commit-queue Add this label to land a pull request using GitHub Actions. label Aug 4, 2026
@nodejs-github-bot
nodejs-github-bot merged commit 3fc98b8 into nodejs:main Aug 4, 2026
70 checks passed
@nodejs-github-bot

Copy link
Copy Markdown
Collaborator

Landed in 3fc98b8

aduh95 pushed a commit that referenced this pull request Aug 4, 2026
readableStreamPipeTo allocated, for every chunk written to the
destination, a { promise, resolve, reject } write request record that
it immediately marked as handled, and drove its loop with an async
step()/run() pair whose implicit promises cost one allocation and one
reaction per iteration. The parked-read path additionally allocated a
read request object, a PromiseWithResolvers record, and a microtask
closure per chunk; this is the steady state for pipeThrough, since a
TransformStream's readable side has a high water mark of zero.

Replace the per-write records with a single per-pipe tracker that the
write request queue holds once per pending write and whose
resolve()/reject() methods maintain a pending-write count, drive the
pump loop with plain callbacks instead of async functions, and reuse
one read request and one forwarding function across all chunks, the
same pattern tee uses since c543cfb.

Benchmark results (benchmark/compare.js --runs 20):
webstreams/pipe-to.js +29.9% to +35.8% across all 16 configurations
(all 99.9% confidence); a pipeThrough(TransformStream) passthrough
loop improves ~17%; every other webstreams benchmark is unchanged.

Signed-off-by: Matteo Collina <hello@matteocollina.com>
PR-URL: #64890
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Yagiz Nizipli <yagiz@nizipli.com>
aduh95 pushed a commit that referenced this pull request Aug 5, 2026
readableStreamPipeTo allocated, for every chunk written to the
destination, a { promise, resolve, reject } write request record that
it immediately marked as handled, and drove its loop with an async
step()/run() pair whose implicit promises cost one allocation and one
reaction per iteration. The parked-read path additionally allocated a
read request object, a PromiseWithResolvers record, and a microtask
closure per chunk; this is the steady state for pipeThrough, since a
TransformStream's readable side has a high water mark of zero.

Replace the per-write records with a single per-pipe tracker that the
write request queue holds once per pending write and whose
resolve()/reject() methods maintain a pending-write count, drive the
pump loop with plain callbacks instead of async functions, and reuse
one read request and one forwarding function across all chunks, the
same pattern tee uses since c543cfb.

Benchmark results (benchmark/compare.js --runs 20):
webstreams/pipe-to.js +29.9% to +35.8% across all 16 configurations
(all 99.9% confidence); a pipeThrough(TransformStream) passthrough
loop improves ~17%; every other webstreams benchmark is unchanged.

Signed-off-by: Matteo Collina <hello@matteocollina.com>
PR-URL: #64890
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Yagiz Nizipli <yagiz@nizipli.com>
aduh95 pushed a commit that referenced this pull request Aug 7, 2026
readableStreamPipeTo allocated, for every chunk written to the
destination, a { promise, resolve, reject } write request record that
it immediately marked as handled, and drove its loop with an async
step()/run() pair whose implicit promises cost one allocation and one
reaction per iteration. The parked-read path additionally allocated a
read request object, a PromiseWithResolvers record, and a microtask
closure per chunk; this is the steady state for pipeThrough, since a
TransformStream's readable side has a high water mark of zero.

Replace the per-write records with a single per-pipe tracker that the
write request queue holds once per pending write and whose
resolve()/reject() methods maintain a pending-write count, drive the
pump loop with plain callbacks instead of async functions, and reuse
one read request and one forwarding function across all chunks, the
same pattern tee uses since c543cfb.

Benchmark results (benchmark/compare.js --runs 20):
webstreams/pipe-to.js +29.9% to +35.8% across all 16 configurations
(all 99.9% confidence); a pipeThrough(TransformStream) passthrough
loop improves ~17%; every other webstreams benchmark is unchanged.

Signed-off-by: Matteo Collina <hello@matteocollina.com>
PR-URL: #64890
Reviewed-By: James M Snell <jasnell@gmail.com>
Reviewed-By: Yagiz Nizipli <yagiz@nizipli.com>
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.

4 participants