Skip to content

Commit 0ff1bfc

Browse files
committed
stream: cut promise churn in webstreams hot paths
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>
1 parent c455f65 commit 0ff1bfc

3 files changed

Lines changed: 126 additions & 25 deletions

File tree

‎lib/internal/webstreams/readablestream.js‎

Lines changed: 62 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ const {
101101
cloneAsUint8Array,
102102
copyArrayBuffer,
103103
createPromiseCallback1Param,
104+
createRawCallback1Param,
104105
customInspect,
105106
defaultSizeAlgorithm,
106107
dequeueValue,
@@ -110,6 +111,7 @@ const {
110111
getNonWritablePropertyDescriptor,
111112
isBrandCheck,
112113
kEmptyQueue,
114+
kResolvedPromise,
113115
kState,
114116
kType,
115117
lazyTransfer,
@@ -121,6 +123,7 @@ const {
121123
resetQueue,
122124
resolvedRecord,
123125
setPromiseHandled,
126+
thenAlgorithmResult,
124127
} = require('internal/webstreams/util');
125128

126129
const {
@@ -137,7 +140,6 @@ const {
137140
writableStreamDefaultWriterRelease,
138141
writableStreamDefaultWriterWriteWithRequest,
139142
writerClosedPromise,
140-
writerReadyPromise,
141143
} = require('internal/webstreams/writablestream');
142144

143145
const { Buffer } = require('buffer');
@@ -1674,11 +1676,31 @@ function readableStreamPipeTo(
16741676
// the chunk travels through `pendingChunk`.
16751677
let pendingChunk;
16761678
let readRequest;
1679+
let readyHook;
16771680

16781681
// Ready promise rejection is handled by the destination-errored
16791682
// watcher.
16801683
function ignoreReadyRejection() {}
16811684

1685+
// Parks the pump on the destination's backpressure by installing a
1686+
// record that duck-types the writer's lazily-materialized
1687+
// [[readyPromise]] record: writableStreamUpdateBackpressure resolves it
1688+
// when backpressure clears (after publishing the new backpressure
1689+
// state), which re-enters the pump directly instead of rotating a
1690+
// fresh promise record plus reaction per flip. The pipe holds the only
1691+
// reference to the writer, so the record is never observable as a real
1692+
// ready promise; the erroring/release paths probe `promise` via
1693+
// isPromisePending() and call `reject`, so it carries a real
1694+
// forever-pending promise and a no-op reject.
1695+
function parkOnReady() {
1696+
readyHook ??= {
1697+
promise: PromiseWithResolvers().promise,
1698+
resolve: pump,
1699+
reject: ignoreReadyRejection,
1700+
};
1701+
writer[kState].ready = readyHook;
1702+
}
1703+
16821704
function forwardChunk() {
16831705
const chunk = pendingChunk;
16841706
pendingChunk = undefined;
@@ -1690,10 +1712,7 @@ function readableStreamPipeTo(
16901712
if (shuttingDown) return;
16911713

16921714
if (dest[kState].backpressure) {
1693-
PromisePrototypeThen(
1694-
writerReadyPromise(writer).promise,
1695-
pump,
1696-
ignoreReadyRejection);
1715+
parkOnReady();
16971716
return;
16981717
}
16991718

@@ -1738,9 +1757,18 @@ function readableStreamPipeTo(
17381757
return;
17391758
}
17401759

1741-
// Yield to microtask queue between batches to allow events/signals
1742-
// to fire
1743-
queueMicrotask(pump);
1760+
// Park on backpressure directly: the ready hook resumes the pump
1761+
// when a completed write clears it.
1762+
if (dest[kState].backpressure) {
1763+
parkOnReady();
1764+
return;
1765+
}
1766+
1767+
// Yield to the microtask queue between batches so completed-write
1768+
// reactions and events/signals fire; a shared resolved promise
1769+
// enqueues the continuation at the same position as queueMicrotask
1770+
// without the per-batch scheduling overhead.
1771+
PromisePrototypeThen(kResolvedPromise, pump);
17441772
return;
17451773
}
17461774

@@ -1752,7 +1780,7 @@ function readableStreamPipeTo(
17521780
// synchronous write during enqueue(). See WHATWG Streams spec
17531781
// "ReadableStreamPipeTo" step 15's "chunk steps".
17541782
pendingChunk = chunk;
1755-
queueMicrotask(forwardChunk);
1783+
PromisePrototypeThen(kResolvedPromise, forwardChunk);
17561784
},
17571785
[kClose]() {},
17581786
[kError]() {},
@@ -1867,7 +1895,7 @@ function readableStreamDefaultTee(stream, cloneForBranch2) {
18671895
// The microtask is required by the spec (ReadableStreamTee's
18681896
// "chunk steps" queue one).
18691897
pendingChunk = value;
1870-
queueMicrotask(forwardChunk);
1898+
PromisePrototypeThen(kResolvedPromise, forwardChunk);
18711899
},
18721900
[kClose]() {
18731901
// The `process.nextTick()` is not part of the spec.
@@ -2020,7 +2048,7 @@ function readableByteStreamTee(stream) {
20202048
defaultReadRequest ??= {
20212049
[kChunk](chunk) {
20222050
pendingChunk = chunk;
2023-
queueMicrotask(forwardChunk);
2051+
PromisePrototypeThen(kResolvedPromise, forwardChunk);
20242052
},
20252053
[kClose]() {
20262054
reading = false;
@@ -2709,8 +2737,18 @@ function readableStreamDefaultControllerPull(controller) {
27092737
controller[kState].pullRejected =
27102738
(error) => readableStreamDefaultControllerError(controller, error);
27112739
}
2712-
PromisePrototypeThen(
2713-
controller[kState].pullAlgorithm(controller),
2740+
// The pull algorithm may be a raw callback (a wrapped user source.pull
2741+
// returns its result uncoerced; a synchronous throw surfaces here) or an
2742+
// internal algorithm that always returns a promise; thenAlgorithmResult
2743+
// handles both.
2744+
let result;
2745+
try {
2746+
result = controller[kState].pullAlgorithm(controller);
2747+
} catch (error) {
2748+
result = PromiseReject(error);
2749+
}
2750+
thenAlgorithmResult(
2751+
result,
27142752
controller[kState].pullFulfilled,
27152753
controller[kState].pullRejected);
27162754
}
@@ -2836,7 +2874,7 @@ function setupReadableStreamDefaultControllerFromSource(
28362874
FunctionPrototypeBind(start, source, controller) :
28372875
nonOpStart;
28382876
const pullAlgorithm = pull ?
2839-
createPromiseCallback1Param('source.pull', pull, source) :
2877+
createRawCallback1Param('source.pull', pull, source) :
28402878
nonOpPull;
28412879
const cancelAlgorithm = cancel ?
28422880
createPromiseCallback1Param('source.cancel', cancel, source) :
@@ -3529,8 +3567,15 @@ function readableByteStreamControllerCallPullIfNeeded(controller) {
35293567
controller[kState].pullRejected =
35303568
(error) => readableByteStreamControllerError(controller, error);
35313569
}
3532-
PromisePrototypeThen(
3533-
controller[kState].pullAlgorithm(controller),
3570+
// See readableStreamDefaultControllerPull for the raw-callback contract.
3571+
let result;
3572+
try {
3573+
result = controller[kState].pullAlgorithm(controller);
3574+
} catch (error) {
3575+
result = PromiseReject(error);
3576+
}
3577+
thenAlgorithmResult(
3578+
result,
35343579
controller[kState].pullFulfilled,
35353580
controller[kState].pullRejected);
35363581
}
@@ -3710,7 +3755,7 @@ function setupReadableByteStreamControllerFromSource(
37103755
FunctionPrototypeBind(start, source, controller) :
37113756
nonOpStart;
37123757
const pullAlgorithm = pull ?
3713-
createPromiseCallback1Param('source.pull', pull, source) :
3758+
createRawCallback1Param('source.pull', pull, source) :
37143759
nonOpPull;
37153760
const cancelAlgorithm = cancel ?
37163761
createPromiseCallback1Param('source.cancel', cancel, source) :

‎lib/internal/webstreams/util.js‎

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,40 @@ function createPromiseCallbackNoParams(name, fn, thisArg) {
338338
return async () => FunctionPrototypeCall(fn, thisArg);
339339
}
340340

341+
// Raw variants that skip the async wrapper's implicit result promise.
342+
// Consumers of a raw callback invoke it inside try/catch and route the
343+
// result through thenAlgorithmResult() below.
344+
function createRawCallback1Param(name, fn, thisArg) {
345+
validateFunction(fn, name);
346+
return (arg) => FunctionPrototypeCall(fn, thisArg, arg);
347+
}
348+
349+
function createRawCallback2Params(name, fn, thisArg) {
350+
validateFunction(fn, name);
351+
return (arg1, arg2) => FunctionPrototypeCall(fn, thisArg, arg1, arg2);
352+
}
353+
354+
// A single shared, forever-resolved promise used to enqueue a reaction at
355+
// the next microtask checkpoint without allocating a fresh promise.
356+
const kResolvedPromise = PromiseResolve();
357+
358+
// Wires the (possibly non-thenable) result of an underlying algorithm
359+
// callback to its fulfilled/rejected continuations. A non-thenable result
360+
// means fulfillment is guaranteed and no then() lookup is observable, so
361+
// the fulfillment step is enqueued directly at the exact microtask
362+
// position the coerced promise's reaction would have had, skipping the
363+
// per-chunk promise allocation. For thenable results PromiseResolve()
364+
// matches the spec's "a promise resolved with" conversion (identity for
365+
// native promises).
366+
function thenAlgorithmResult(result, onFulfilled, onRejected) {
367+
if (result === null ||
368+
(typeof result !== 'object' && typeof result !== 'function')) {
369+
PromisePrototypeThen(kResolvedPromise, onFulfilled);
370+
} else {
371+
PromisePrototypeThen(PromiseResolve(result), onFulfilled, onRejected);
372+
}
373+
}
374+
341375
function createPromiseCallback1Param(name, fn, thisArg) {
342376
validateFunction(fn, name);
343377
return async (arg) => FunctionPrototypeCall(fn, thisArg, arg);
@@ -386,11 +420,14 @@ async function nonOpFlush() {}
386420

387421
function nonOpStart() {}
388422

389-
async function nonOpPull() {}
423+
// nonOpPull and nonOpWrite are raw callbacks (see createRawCallback*):
424+
// their non-thenable return takes the allocation-free fast path in
425+
// thenAlgorithmResult().
426+
function nonOpPull() {}
390427

391428
async function nonOpCancel() {}
392429

393-
async function nonOpWrite() {}
430+
function nonOpWrite() {}
394431

395432
let transfer;
396433
function lazyTransfer() {
@@ -411,6 +448,8 @@ module.exports = {
411448
createPromiseCallbackNoParams,
412449
createPromiseCallback1Param,
413450
createPromiseCallback2Params,
451+
createRawCallback1Param,
452+
createRawCallback2Params,
414453
customInspect,
415454
defaultSizeAlgorithm,
416455
dequeueValue,
@@ -421,6 +460,7 @@ module.exports = {
421460
isBrandCheck,
422461
isPromisePending,
423462
kEmptyQueue,
463+
kResolvedPromise,
424464
kState,
425465
kType,
426466
lazyTransfer,
@@ -435,4 +475,5 @@ module.exports = {
435475
resetQueue,
436476
resolvedRecord,
437477
setPromiseHandled,
478+
thenAlgorithmResult,
438479
};

‎lib/internal/webstreams/writablestream.js‎

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ const {
5656
Queue,
5757
createPromiseCallbackNoParams,
5858
createPromiseCallback1Param,
59-
createPromiseCallback2Params,
59+
createRawCallback2Params,
6060
customInspect,
6161
defaultSizeAlgorithm,
6262
dequeueValue,
@@ -78,6 +78,7 @@ const {
7878
resetQueue,
7979
resolvedRecord,
8080
setPromiseHandled,
81+
thenAlgorithmResult,
8182
} = require('internal/webstreams/util');
8283

8384
const {
@@ -769,7 +770,12 @@ function writableStreamUpdateBackpressure(controller, streamState) {
769770
const backpressure =
770771
controllerState.highWaterMark - controllerState.queueTotalSize <= 0;
771772
const writer = streamState.writer;
772-
if (writer !== undefined && streamState.backpressure !== backpressure) {
773+
const changed = streamState.backpressure !== backpressure;
774+
// The state field is published before the ready record is resolved so
775+
// that a ready resolve hook (pipeTo's pump continuation) observes the
776+
// new value.
777+
streamState.backpressure = backpressure;
778+
if (writer !== undefined && changed) {
773779
if (backpressure) {
774780
// The spec replaces [[readyPromise]] with a fresh pending promise;
775781
// dropping the cache lets the next observation derive it.
@@ -778,7 +784,6 @@ function writableStreamUpdateBackpressure(controller, streamState) {
778784
writer[kState].ready?.resolve();
779785
}
780786
}
781-
streamState.backpressure = backpressure;
782787
}
783788

784789
function writableStreamStartErroring(stream, reason) {
@@ -1197,8 +1202,18 @@ function writableStreamDefaultControllerProcessWrite(controller, chunk) {
11971202
};
11981203
}
11991204

1200-
PromisePrototypeThen(
1201-
writeAlgorithm(chunk, controller),
1205+
// The write algorithm may be a raw callback (a wrapped user sink.write
1206+
// returns its result uncoerced; a synchronous throw surfaces here) or an
1207+
// internal algorithm that always returns a promise; thenAlgorithmResult
1208+
// handles both.
1209+
let result;
1210+
try {
1211+
result = writeAlgorithm(chunk, controller);
1212+
} catch (error) {
1213+
result = PromiseReject(error);
1214+
}
1215+
thenAlgorithmResult(
1216+
result,
12021217
controller[kState].writeFulfilled,
12031218
controller[kState].writeRejected);
12041219
}
@@ -1323,7 +1338,7 @@ function setupWritableStreamDefaultControllerFromSink(
13231338
FunctionPrototypeBind(start, sink, controller) :
13241339
nonOpStart;
13251340
const writeAlgorithm = write ?
1326-
createPromiseCallback2Params('sink.write', write, sink) :
1341+
createRawCallback2Params('sink.write', write, sink) :
13271342
nonOpWrite;
13281343
const closeAlgorithm = close ?
13291344
createPromiseCallbackNoParams('sink.close', close, sink) :

0 commit comments

Comments
 (0)