stream: decouple transform backpressure changes - #65143
Conversation
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>
|
Review requested:
|
Codecov Reportβ
All modified and coverable lines are covered by tests. 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
π New features to boost your workflow:
|
pacocartones
left a comment
There was a problem hiding this comment.
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:
writeContinuationis cached for the stream's lifetime via??=; negligible, but it could be cleared after use for symmetry withpendingWrite/pendingWriteChunk.- The
nonOp*consolidation is a pure merge (same raw-callback contract); the benchmark additions are additive.
LGTM.
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 thepipeThroughpassthrough shape both flips are observed on every chunk, so the record plus its reactions and theperformTransformasync-wrapper pair accounted for roughly 10% of the profile.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.performTransform.transformStreamDefaultControllerPerformTransformnow mirrors the reference implementation'spromiseCall(transformAlgorithm, β¦).then(undefined, rejectionSteps)directly: thetransformer.transformcallback 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.pipe-throughbenchmark, since the suite had no transform-throughput row.Benchmark results (30 runs, vs the #65138 head):
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, chainedpipeThrough) whose event log is byte-identical to the #65138 head.