Skip to content

stream: keep webstream stream states in fast-mode objects - #65625

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

stream: keep webstream stream states in fast-mode objects#65625
mcollina wants to merge 2 commits into
nodejs:mainfrom
mcollina:webstream-perf-round16

Conversation

@mcollina

Copy link
Copy Markdown
Member

While profiling short-lived streams (the per-request create → few chunks → close pattern), createReadableStreamState showed up at 20% self time. The cause: the per-stream state records are object literals with __proto__: null, which V8 creates as dictionary-mode objects — roughly 50x slower to allocate (~500-1500ns vs ~30ns), and every subsequent property load on them is a dictionary lookup. These records back every hot path, so both construction and per-chunk [kState] accesses have been paying for it.

The first commit replaces the four per-stream state literals (and their nested transfer records) with classes whose prototype has a null prototype: instances stay in fast mode, while Object.prototype remains excluded from the lookup chain, preserving the pollution protection the literals were there for. Every field ever assigned is declared up front so the shape never transitions. (The controller and reader states were already plain literals and unaffected.) It also adds benchmark/webstreams/lifecycle.js covering the short-lived stream pattern that exposed this.

The second commit removes the per-chunk PromiseWithResolvers for transform sink writes parked on backpressure: the writable controller's write reactions exist before the write algorithm runs, so the parked-write continuation can wire the perform-transform promise directly to them via the parked-result sentinel introduced in #65143, dropping the promise record and the thenable adoption hop.

Results (30 runs, all 36 rows significant at ***, no regressions — full table below):

                                                                       confidence improvement accuracy (*)    (**)   (***)
webstreams/creation.js kind='ReadableStream.tee' n=50000                      ***     71.39 %      ±11.02% ±14.79% ±19.52%
webstreams/creation.js kind='ReadableStream' n=50000                          ***    138.13 %      ±14.29% ±19.14% ±25.17%
webstreams/creation.js kind='ReadableStreamBYOBReader' n=50000                ***     74.72 %       ±9.66% ±12.97% ±17.11%
webstreams/creation.js kind='ReadableStreamDefaultReader' n=50000             ***     75.04 %      ±11.10% ±14.87% ±19.58%
webstreams/creation.js kind='TransformStream' n=50000                         ***    133.64 %       ±8.61% ±11.46% ±14.94%
webstreams/creation.js kind='WritableStream' n=50000                          ***    204.00 %       ±7.70% ±10.26% ±13.37%
webstreams/encoding-streams.js len=1024 kind='decode' n=100000                ***     57.21 %       ±3.11%  ±4.15%  ±5.42%
webstreams/encoding-streams.js len=1024 kind='encode' n=100000                ***     31.57 %       ±1.86%  ±2.48%  ±3.24%
webstreams/encoding-streams.js len=16 kind='decode' n=100000                  ***     57.54 %       ±3.19%  ±4.27%  ±5.61%
webstreams/encoding-streams.js len=16 kind='encode' n=100000                  ***     44.84 %       ±2.38%  ±3.17%  ±4.15%
webstreams/from.js kind='async' n=1000000                                     ***     32.60 %       ±1.34%  ±1.79%  ±2.35%
webstreams/from.js kind='sync' n=1000000                                      ***     39.66 %       ±1.41%  ±1.88%  ±2.44%
webstreams/js_transfer.js n=10000 payload='ReadableStream'                    ***     10.98 %       ±1.56%  ±2.08%  ±2.72%
webstreams/js_transfer.js n=10000 payload='TransformStream'                   ***     12.90 %       ±0.88%  ±1.18%  ±1.53%
webstreams/js_transfer.js n=10000 payload='WritableStream'                    ***     11.77 %       ±0.96%  ±1.28%  ±1.67%
webstreams/lifecycle.js kind='pipe-through' n=50000                           ***     96.41 %       ±3.41%  ±4.58%  ±6.03%
webstreams/lifecycle.js kind='pipe-to' n=50000                                ***     80.15 %       ±7.47% ±10.02% ±13.18%
webstreams/lifecycle.js kind='readable' n=50000                               ***     80.04 %       ±3.10%  ±4.15%  ±5.45%
webstreams/pipe-through.js kind='default' n=500000                            ***     91.16 %       ±2.56%  ±3.43%  ±4.52%
webstreams/pipe-through.js kind='transform' n=500000                          ***     93.68 %       ±3.12%  ±4.19%  ±5.53%
webstreams/pipe-to.js highWaterMarkW=1 highWaterMarkR=1 n=500000              ***    110.48 %       ±2.91%  ±3.89%  ±5.11%
webstreams/pipe-to.js highWaterMarkW=1 highWaterMarkR=1024 n=500000           ***    105.83 %       ±3.89%  ±5.22%  ±6.89%
webstreams/pipe-to.js highWaterMarkW=1 highWaterMarkR=4096 n=500000           ***    109.78 %       ±2.47%  ±3.30%  ±4.33%
webstreams/pipe-to.js highWaterMarkW=1024 highWaterMarkR=1 n=500000           ***    110.77 %       ±2.07%  ±2.78%  ±3.64%
webstreams/pipe-to.js highWaterMarkW=1024 highWaterMarkR=1024 n=500000        ***    109.64 %       ±2.43%  ±3.26%  ±4.30%
webstreams/pipe-to.js highWaterMarkW=1024 highWaterMarkR=4096 n=500000        ***    111.70 %       ±2.52%  ±3.38%  ±4.46%
webstreams/pipe-to.js highWaterMarkW=4096 highWaterMarkR=1 n=500000           ***    110.87 %       ±2.50%  ±3.34%  ±4.37%
webstreams/pipe-to.js highWaterMarkW=4096 highWaterMarkR=1024 n=500000        ***    108.40 %       ±2.36%  ±3.15%  ±4.13%
webstreams/pipe-to.js highWaterMarkW=4096 highWaterMarkR=4096 n=500000        ***    109.79 %       ±2.49%  ±3.32%  ±4.36%
webstreams/readable-async-iterator.js type='bytes' n=100000                   ***     12.19 %       ±2.06%  ±2.74%  ±3.57%
webstreams/readable-async-iterator.js type='normal' n=100000                  ***     15.03 %       ±4.79%  ±6.37%  ±8.30%
webstreams/readable-read-buffered.js bufferSize=1 n=100000                    ***     22.05 %       ±5.25%  ±6.99%  ±9.10%
webstreams/readable-read-buffered.js bufferSize=10 n=100000                   ***     19.89 %       ±3.04%  ±4.04%  ±5.26%
webstreams/readable-read-buffered.js bufferSize=100 n=100000                  ***     27.79 %       ±5.78%  ±7.70% ±10.04%
webstreams/readable-read-buffered.js bufferSize=1000 n=100000                 ***     25.51 %       ±5.83%  ±7.76% ±10.11%
webstreams/readable-read.js type='byob' n=100000                              ***      5.82 %       ±1.45%  ±1.93%  ±2.51%
webstreams/readable-read.js type='normal' n=100000                            ***     34.63 %       ±4.79%  ±6.38%  ±8.31%
webstreams/tee.js type='bytes' n=100000                                       ***     13.65 %       ±1.49%  ±1.98%  ±2.58%
webstreams/tee.js type='normal' n=100000                                      ***     15.97 %       ±2.70%  ±3.59%  ±4.68%

Validated with the full webstreams test suite, WPT streams/compression/encoding, and two differential stress harnesses (adapter scenarios and transform parked-write error/abort/terminate/reentrancy scenarios) whose observable event logs are byte-identical to the previous implementation.

Other lib/ modules use the same __proto__: null literal pattern on hot paths; I'll follow up separately after auditing them.

The per-stream state records were built as object literals with
__proto__: null, which V8 creates as dictionary-mode objects: roughly
50x slower to allocate, and every subsequent property load on them is a
dictionary lookup. These records back every hot path, so both stream
construction and per-chunk field accesses were paying for it.

Replace the literals with classes whose prototype has a null prototype,
so instances stay in fast mode while Object.prototype remains excluded
from the lookup chain. Every field ever assigned is declared up front
so the shape never transitions.

Also add benchmark/webstreams/lifecycle.js covering the short-lived
stream pattern (create, few chunks, close) that first exposed this.

                                       confidence improvement accuracy (*)   (**)  (***)
webstreams/creation.js kind='ReadableStream' n=50000            ***    138.13 %      ±14.29% ±19.14% ±25.17%
webstreams/creation.js kind='TransformStream' n=50000           ***    133.64 %       ±8.61% ±11.46% ±14.94%
webstreams/creation.js kind='WritableStream' n=50000            ***    204.00 %       ±7.70% ±10.26% ±13.37%
webstreams/lifecycle.js kind='pipe-through' n=50000             ***     96.41 %       ±3.41%  ±4.58%  ±6.03%
webstreams/lifecycle.js kind='readable' n=50000                 ***     80.04 %       ±3.10%  ±4.15%  ±5.45%
webstreams/pipe-through.js kind='default' n=500000              ***     91.16 %       ±2.56%  ±3.43%  ±4.52%
webstreams/pipe-to.js highWaterMarkW=1 highWaterMarkR=1 n=500000 ***    110.48 %       ±2.91%  ±3.89%  ±5.11%
webstreams/readable-read.js type='normal' n=100000              ***     34.63 %       ±4.79%  ±6.38%  ±8.31%

Signed-off-by: Matteo Collina <hello@matteocollina.com>
A transform sink write arriving under backpressure parked the chunk
together with a PromiseWithResolvers record whose promise was returned
to the writable controller and later resolved with the perform-transform
promise. The writable's write reactions already exist before the write
algorithm runs, so the parked write can instead return the parked-result
sentinel and have the continuation wire the perform-transform promise
directly to those reactions, dropping the per-chunk promise record and
the thenable adoption hop. Failures while erroring are delivered in a
microtask, preserving the old rejection position.

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 Aug 28, 2026
@codecov

codecov Bot commented Aug 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.85039% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.07%. Comparing base (7aaf9b4) to head (36d534f).
⚠️ Report is 59 commits behind head on main.

Files with missing lines Patch % Lines
lib/internal/webstreams/transformstream.js 93.65% 4 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #65625      +/-   ##
==========================================
- Coverage   90.13%   90.07%   -0.07%     
==========================================
  Files         751      751              
  Lines      253639   255020    +1381     
  Branches    47790    48157     +367     
==========================================
+ Hits       228618   229703    +1085     
- Misses      16264    16482     +218     
- Partials     8757     8835      +78     
Files with missing lines Coverage Δ
lib/internal/webstreams/readablestream.js 87.24% <100.00%> (+0.03%) ⬆️
lib/internal/webstreams/writablestream.js 93.79% <100.00%> (+0.30%) ⬆️
lib/internal/webstreams/transformstream.js 93.68% <93.65%> (-0.29%) ⬇️

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

Comment on lines +40 to +41
await new ReadableStream(makeSource())
.pipeTo(new WritableStream({ write() {} }));

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.

nit: unlikely but we can add asserts for dead-code elimination

Comment on lines +1422 to +1428
// The state records are classes whose prototype chain ends at null
// instead of `__proto__: null` object literals: the literals fall back
// to dictionary-mode objects in V8 (~50x slower to create, and every
// later property load is a dictionary lookup), while class instances
// stay in fast mode with the same protection against Object.prototype
// pollution. Every field ever assigned is declared so the shape never
// transitions.

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.

nit:

I remember to read few comments like this in the past due to assumptions of V8 at the time, but they usually are very niche/unique/different than a normal implementation

This one does not look weird/hacky, so I would say we could just remove those comments since they don't agreggate much value and you have added benchmarks so we can catch regressions

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.

3 participants