Skip to content

stream: decouple transform backpressure changes - #65143

Open
mcollina wants to merge 4 commits into
nodejs:mainfrom
mcollina:webstream-perf-round13
Open

stream: decouple transform backpressure changes#65143
mcollina wants to merge 4 commits into
nodejs:mainfrom
mcollina:webstream-perf-round13

Conversation

@mcollina

@mcollina mcollina commented Aug 8, 2026

Copy link
Copy Markdown
Member

Thirteenth round of pure-JS webstreams optimizations. Stacked on #65138 β€” the first three commits are that PR; please review only the last commit (stream: decouple transform backpressure changes).

The transform stream's [[backpressureChangePromise]] is observed only internally: by the source pull algorithm (settles when backpressure next becomes true) and by a sink write arriving under backpressure (settles when it next becomes false). On the pipeThrough passthrough shape both flips are observed on every chunk, so the record plus its reactions and the performTransform async-wrapper pair accounted for roughly 10% of the profile.

  • Continuation slots instead of the promise record. A parked pull returns a sentinel (kParkedAlgorithmResult) and the next backpressureβ†’true flip enqueues the readable controller's cached pull-fulfilled step on the shared resolved promise β€” the exact microtask position of the old record's reaction. A parked write stores the chunk plus one promise record; the false-flip runs a cached per-stream continuation that resolves it with the perform-transform promise, and adoption reproduces the previous derived-chain settle depth bit-for-bit. Erroring/cancel paths deliver through the same flip (transformStreamUnblockWrite), rejecting inside the continuation microtask like the old reaction-throw did.
  • Reference-exact performTransform. transformStreamDefaultControllerPerformTransform now mirrors the reference implementation's promiseCall(transformAlgorithm, …).then(undefined, rejectionSteps) directly: the transformer.transform callback is wrapped raw, a non-thenable result reuses the shared resolved promise, and a synchronous throw is delivered through a rejected promise so the stream is still errored inside a reaction, not synchronously.
  • New pipe-through benchmark, since the suite had no transform-throughput row.

Benchmark results (30 runs, vs the #65138 head):

                                                                  confidence improvement accuracy (*)    (**)   (***)
webstreams/pipe-through.js kind='default' n=500000                         *      8.73 %       Β±7.15%  Β±9.52% Β±12.39%
webstreams/pipe-through.js kind='transform' n=500000                              8.34 %       Β±8.80% Β±11.70% Β±15.24%

A writer-driven write()/read() transform loop improves ~14% in spot runs. creation, js_transfer, pipe-to, and the read families are unchanged (all n.s.; the machine's ambient load makes the confidence intervals wide β€” direction was consistent across interleaved spot runs).

Verified with the WPT streams/compression/encoding suites, the full parallel webstream/whatwg set, and a transform-specific ordering stress (writer-driven backpressure parking with microtask-depth probes, reentrant reads inside transform(), terminate() mid-stream, rejected/throwing transforms, reader cancel with a parked write, chained pipeThrough) whose event log is byte-identical to the #65138 head.

The highWaterMark values were passed as properties of the underlying
source and sink dictionaries, where they are ignored: a queuing
strategy's highWaterMark is read from the constructors' second argument.
Every configuration therefore measured the identical workload at the
default highWaterMark of 1, which also explains the historically high
run-to-run variance of this benchmark family.

Pass the strategies as the constructors' second argument and cover the
default (1) alongside buffered (1024, 4096) configurations.

Signed-off-by: Matteo Collina <hello@matteocollina.com>
Three related reductions on the per-chunk paths:

Wrap user sink.write and source.pull callbacks without coercing their
result into a promise. When the callback returns a non-thenable (the
common synchronous case), fulfillment is guaranteed and no then() lookup
is observable, so the fulfilled reaction is enqueued through a single
shared resolved promise at the exact microtask position the coerced
promise's reaction would have had, skipping the implicit async-wrapper
promise per chunk. Thenable results go through PromiseResolve(), which
matches the spec's "a promise resolved with" conversion (identity for
native promises).

Park pipeTo's pump on backpressure by installing a record that
duck-types the writer's lazily-materialized [[readyPromise]] record and
whose resolve function is the pump continuation itself. Backpressure
clearing then resumes the pump directly instead of materializing a fresh
promise record plus reaction per flip, and the pump no longer schedules
a microtask per batch. writableStreamUpdateBackpressure publishes the
new backpressure state before resolving the ready record so the pump
observes the updated value.

Replace queueMicrotask() on the pipeTo and tee chunk-forwarding paths
with a reaction on the shared resolved promise, which enqueues the
continuation at the same position without the per-call scheduling
overhead.

pipe-to improves by 8-14% across all benchmark configurations, with
readable-read and tee also improving in spot runs.

Signed-off-by: Matteo Collina <hello@matteocollina.com>
The start, pull, and write non-op algorithms are all raw callbacks with
an identical empty body now, so a single shared nonOpCallback replaces
nonOpStart, nonOpPull, and nonOpWrite.

Signed-off-by: Matteo Collina <hello@matteocollina.com>
The spec's [[backpressureChangePromise]] is only ever observed by the
transform source pull algorithm (settles when backpressure next becomes
true) and by a sink write arriving under backpressure (settles when it
next becomes false), both internal. Replace the promise record with
direct continuation delivery: a parked pull is completed by enqueueing
the readable controller's pull-fulfilled step on the shared resolved
promise, and a parked write by a cached per-stream continuation that
resolves the sink promise with the perform-transform promise, so
adoption reproduces the previous derived-chain settle depth exactly.
Each delivery lands at the same microtask position as the old record's
reaction.

transformStreamDefaultControllerPerformTransform now mirrors the
reference implementation's promiseCall().then(undefined, rejection
steps) directly instead of running an async wrapper pair per chunk: the
transformer.transform callback is wrapped raw, a non-thenable result
reuses the shared resolved promise, and a synchronous throw is
delivered through a rejected promise, keeping the error timing inside a
reaction.

A pipe-through benchmark is added since the suite had no
transform-throughput row.

pipeThrough passthrough improves by ~8% and a writer-driven transform
loop by ~14%; the pipe-to and read families are unchanged.

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

codecov Bot commented Aug 8, 2026

Copy link
Copy Markdown

Codecov Report

βœ… All modified and coverable lines are covered by tests.
βœ… Project coverage is 90.33%. Comparing base (347e266) to head (0195e8e).
⚠️ Report is 9 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main   #65143      +/-   ##
==========================================
+ Coverage   90.30%   90.33%   +0.02%     
==========================================
  Files         759      759              
  Lines      248294   248489     +195     
  Branches    46860    46881      +21     
==========================================
+ Hits       224220   224465     +245     
+ Misses      15516    15468      -48     
+ Partials     8558     8556       -2     
Files with missing lines Coverage Ξ”
lib/internal/webstreams/readablestream.js 98.18% <100.00%> (+0.02%) ⬆️
lib/internal/webstreams/transformstream.js 98.68% <100.00%> (+0.12%) ⬆️
lib/internal/webstreams/util.js 97.10% <100.00%> (-0.61%) ⬇️
lib/internal/webstreams/writablestream.js 99.52% <100.00%> (+<0.01%) ⬆️

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

@pacocartones pacocartones left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Independent verification β€” continuation slots are behavior-equivalent to [[backpressureChangePromise]]

Reviewed the last commit (0195e8e2cd) in isolation against its parent (0ff1bfc362). I traced every transition and the refactor preserves observable behavior; the design is sound.

Mutual exclusion of the two parked states: a pull is parked while backpressure is false (the pull algorithm sets it false, then parks), a write while it is true. The assert(state.backpressure !== backpressure) guarantees one direction per call, so the true-flip can only deliver a parked pull and the false-flip only a parked write β€” never both, no ambiguity.

Pull side: pullFulfilled is created by the readable controller before the pull algorithm is invoked (readableStreamDefaultControllerCallPullIfNeeded creates it lazily on first pull), and the algorithm-clearing paths don't touch it β€” so it is always present when a flip delivers it. kParkedAlgorithmResult makes thenAlgorithmResult return early, which leaves controller[kState].pulling = true (correct: the pull is in flight) until the true-flip schedules pullFulfilled on the shared resolved promise. That is the same settle depth the old materialized change promise had, and since that promise was only ever resolved, the skipped rejection path is genuinely unreachable. pullAgain re-pull works because pullFulfilled clears pulling and re-invokes callPullIfNeeded.

Write side: one pending slot is safe because the writable dispatches writes serially (the assert(state.pendingWrite === undefined) backs the comment). The cached continuation rejects with storedError when erroring, otherwise resolves the pending write with the perform-transform result; resolve-with-promise adoption reproduces the old derived-chain settle depth exactly.

performTransform: the raw callback plus the memoized performTransformRejected mirrors the reference promiseCall(...).then(undefined, rejectionSteps): results are coerced with PromiseResolve, non-thenables take the kResolvedPromise fast path (same microtask depth as the old await), and a rejection errors the stream before rethrowing. The transformAlgorithm === undefined early return covers the concurrent cancel/abort/close clear, matching the old return; in the async wrapper.

Shared kResolvedPromise vs per-stream promises: reactions on an already-resolved promise run FIFO at the next microtask checkpoint, and flips schedule them in flip order, so cross-stream ordering matches the old per-stream change promises.

Erroring while parked: if the stream errors while backpressure stays true, the parked write waits for a flip in both the old and new code (the change promise was never rejected) β€” spec-conformant, no regression.

Two non-blocking nits:

  • writeContinuation is cached for the stream's lifetime via ??=; negligible, but it could be cleared after use for symmetry with pendingWrite/pendingWriteChunk.
  • The nonOp* consolidation is a pure merge (same raw-callback contract); the benchmark additions are additive.

LGTM.

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.

3 participants