Skip to content

Commit 1b0597b

Browse files
mcollinaaduh95
authored andcommitted
stream: cut per-chunk allocations in pipeTo
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>
1 parent 718cbe9 commit 1b0597b

3 files changed

Lines changed: 310 additions & 65 deletions

File tree

‎lib/internal/webstreams/readablestream.js‎

Lines changed: 92 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,6 @@ const {
4646
DOMException,
4747
}=internalBinding('messaging');
4848

49-
const{
50-
markPromiseAsHandled,
51-
}=internalBinding('util');
52-
5349
const{
5450
isArrayBufferView,
5551
isDataView,
@@ -139,7 +135,7 @@ const {
139135
writableStreamCloseQueuedOrInFlight,
140136
writableStreamDefaultWriterCloseWithErrorPropagation,
141137
writableStreamDefaultWriterRelease,
142-
writableStreamDefaultWriterWrite,
138+
writableStreamDefaultWriterWriteWithRequest,
143139
writerClosedPromise,
144140
writerReadyPromise,
145141
}=require('internal/webstreams/writablestream');
@@ -1530,8 +1526,38 @@ function readableStreamPipeTo(
15301526

15311527
constpromise=PromiseWithResolvers();
15321528

1533-
conststate={
1534-
currentWrite: PromiseResolve(),
1529+
// One shared write request tracks every chunk written to the
1530+
// destination, instead of a { promise, resolve, reject } record per
1531+
// write. `stall` is armed by waitForPendingWrites() during shutdown;
1532+
// `failed`/`failure` latch a write that could not proceed.
1533+
constwriteTracker={
1534+
// Non-undefined: queue entries are discriminated from kNilRequest
1535+
// by `promise === undefined`.
1536+
promise: null,
1537+
pending: 0,
1538+
failed: false,
1539+
failure: undefined,
1540+
stall: null,
1541+
resolve(){
1542+
if(--this.pending===0&&this.stall!==null){
1543+
conststall=this.stall;
1544+
this.stall=null;
1545+
if(this.failed)
1546+
stall.reject(this.failure);
1547+
else
1548+
stall.resolve();
1549+
}
1550+
},
1551+
reject(error){
1552+
this.pending--;
1553+
this.failed=true;
1554+
this.failure=error;
1555+
if(this.stall!==null){
1556+
conststall=this.stall;
1557+
this.stall=null;
1558+
stall.reject(error);
1559+
}
1560+
},
15351561
};
15361562

15371563
// The error here can be undefined. The rejected arg
@@ -1548,11 +1574,14 @@ function readableStreamPipeTo(
15481574
promise.resolve();
15491575
}
15501576

1551-
asyncfunctionwaitForCurrentWrite(){
1552-
constwrite=state.currentWrite;
1553-
awaitwrite;
1554-
if(write!==state.currentWrite)
1555-
awaitwaitForCurrentWrite();
1577+
functionwaitForPendingWrites(){
1578+
if(writeTracker.pending===0){
1579+
returnwriteTracker.failed ?
1580+
PromiseReject(writeTracker.failure) :
1581+
PromiseResolve();
1582+
}
1583+
writeTracker.stall=PromiseWithResolvers();
1584+
returnwriteTracker.stall.promise;
15561585
}
15571586

15581587
functionshutdownWithAnAction(action,rejected,originalError){
@@ -1561,7 +1590,7 @@ function readableStreamPipeTo(
15611590
if(dest[kState].state==='writable'&&
15621591
!writableStreamCloseQueuedOrInFlight(dest)){
15631592
PromisePrototypeThen(
1564-
waitForCurrentWrite(),
1593+
waitForPendingWrites(),
15651594
complete,
15661595
(error)=>finalize(true,error));
15671596
return;
@@ -1582,7 +1611,7 @@ function readableStreamPipeTo(
15821611
if(dest[kState].state==='writable'&&
15831612
!writableStreamCloseQueuedOrInFlight(dest)){
15841613
PromisePrototypeThen(
1585-
waitForCurrentWrite(),
1614+
waitForPendingWrites(),
15861615
()=>finalize(rejected,error),
15871616
(error)=>finalize(true,error));
15881617
return;
@@ -1639,25 +1668,46 @@ function readableStreamPipeTo(
16391668
PromisePrototypeThen(promise,action,()=>{});
16401669
}
16411670

1642-
asyncfunctionstep(){
1643-
if(shuttingDown)returntrue;
1671+
// The pump loop is callback-driven to avoid per-iteration promise
1672+
// allocations. At most one read is in flight at a time, so one read
1673+
// request and one forwarding function are reused for every chunk;
1674+
// the chunk travels through `pendingChunk`.
1675+
letpendingChunk;
1676+
letreadRequest;
1677+
1678+
// Ready promise rejection is handled by the destination-errored
1679+
// watcher.
1680+
functionignoreReadyRejection(){}
1681+
1682+
functionforwardChunk(){
1683+
constchunk=pendingChunk;
1684+
pendingChunk=undefined;
1685+
writableStreamDefaultWriterWriteWithRequest(writer,chunk,writeTracker);
1686+
pump();
1687+
}
1688+
1689+
functionpump(){
1690+
if(shuttingDown)return;
16441691

16451692
if(dest[kState].backpressure){
1646-
awaitwriterReadyPromise(writer).promise;
1647-
if(shuttingDown)returntrue;
1693+
PromisePrototypeThen(
1694+
writerReadyPromise(writer).promise,
1695+
pump,
1696+
ignoreReadyRejection);
1697+
return;
16481698
}
16491699

16501700
constcontroller=source[kState].controller;
16511701

16521702
// Fast path: batch reads when data is buffered in a default controller.
1653-
// This avoids creating PipeToReadableStreamReadRequest objects and
1654-
// reduces promise allocation overhead.
1703+
// This avoids parking read requests and reduces promise allocation
1704+
// overhead.
16551705
if(source[kState].state==='readable'&&
16561706
isReadableStreamDefaultController(controller)&&
16571707
controller[kState].queue.length>0){
16581708

16591709
while(controller[kState].queue.length>0){
1660-
if(shuttingDown)returntrue;
1710+
if(shuttingDown)return;
16611711

16621712
constchunk=dequeueValue(controller);
16631713

@@ -1668,8 +1718,7 @@ function readableStreamPipeTo(
16681718

16691719
// Write the chunk - we're already in a separate microtask from enqueue
16701720
// because we awaited the writer ready promise above.
1671-
state.currentWrite=writableStreamDefaultWriterWrite(writer,chunk);
1672-
markPromiseAsHandled(state.currentWrite);
1721+
writableStreamDefaultWriterWriteWithRequest(writer,chunk,writeTracker);
16731722

16741723
// Check backpressure after each write
16751724
if(dest[kState].backpressure){
@@ -1686,24 +1735,29 @@ function readableStreamPipeTo(
16861735

16871736
// Check if stream closed during batch
16881737
if(source[kState].state==='closed'){
1689-
returntrue;
1738+
return;
16901739
}
16911740

1692-
// Yield to microtask queue between batches to allow events/signals to fire
1693-
returnfalse;
1741+
// Yield to microtask queue between batches to allow events/signals
1742+
// to fire
1743+
queueMicrotask(pump);
1744+
return;
16941745
}
16951746

1696-
// Slow path: use read request for async reads
1697-
constpromise=PromiseWithResolvers();
1698-
// eslint-disable-next-line no-use-before-define
1699-
readableStreamDefaultReaderRead(reader,newPipeToReadableStreamReadRequest(writer,state,promise));
1700-
1701-
returnpromise.promise;
1702-
}
1703-
1704-
asyncfunctionrun(){
1705-
// Run until step resolves as true
1706-
while(!awaitstep());
1747+
// Slow path: park a lazily materialized read request. Close and
1748+
// error are handled by the source watchers.
1749+
readRequest??={
1750+
[kChunk](chunk){
1751+
// Per spec, pipeTo must queue a microtask for the write to avoid
1752+
// synchronous write during enqueue(). See WHATWG Streams spec
1753+
// "ReadableStreamPipeTo" step 15's "chunk steps".
1754+
pendingChunk=chunk;
1755+
queueMicrotask(forwardChunk);
1756+
},
1757+
[kClose](){},
1758+
[kError](){},
1759+
};
1760+
readableStreamDefaultReaderRead(reader,readRequest);
17071761
}
17081762

17091763
if(signal!==undefined){
@@ -1715,7 +1769,7 @@ function readableStreamPipeTo(
17151769
disposable=addAbortListener(signal,abortAlgorithm);
17161770
}
17171771

1718-
setPromiseHandled(run());
1772+
pump();
17191773

17201774
watchErrored(source,readerClosedPromise(reader).promise,(error)=>{
17211775
if(!preventAbort){
@@ -1760,33 +1814,6 @@ function readableStreamPipeTo(
17601814
returnpromise.promise;
17611815
}
17621816

1763-
classPipeToReadableStreamReadRequest{
1764-
constructor(writer,state,promise){
1765-
this.writer=writer;
1766-
this.state=state;
1767-
this.promise=promise;
1768-
}
1769-
1770-
[kChunk](chunk){
1771-
// Per spec, pipeTo must queue a microtask for the write to avoid
1772-
// synchronous write during enqueue(). See WHATWG Streams spec
1773-
// "ReadableStreamPipeTo" step 15's "chunk steps".
1774-
queueMicrotask(()=>{
1775-
this.state.currentWrite=writableStreamDefaultWriterWrite(this.writer,chunk);
1776-
markPromiseAsHandled(this.state.currentWrite);
1777-
this.promise.resolve(false);
1778-
});
1779-
}
1780-
1781-
[kClose](){
1782-
this.promise.resolve(true);
1783-
}
1784-
1785-
[kError](error){
1786-
this.promise.reject(error);
1787-
}
1788-
}
1789-
17901817
functionreadableStreamTee(stream,cloneForBranch2){
17911818
if(isReadableByteStreamController(stream[kState].controller)){
17921819
returnreadableByteStreamTee(stream);

‎lib/internal/webstreams/writablestream.js‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1001,6 +1001,61 @@ function writableStreamDefaultWriterWrite(writer, chunk) {
10011001
returnpromise;
10021002
}
10031003

1004+
// Variant of writableStreamDefaultWriterWrite for pipeTo: the caller
1005+
// provides a shared request object instead of a per-write promise record.
1006+
// `pending` is incremented before the controller write, which can settle
1007+
// requests synchronously when the stream starts erroring; precondition
1008+
// failures are latched on `failed`/`failure`.
1009+
functionwritableStreamDefaultWriterWriteWithRequest(writer,chunk,request){
1010+
constwriterState=writer[kState];
1011+
conststream=writerState.stream;
1012+
assert(stream!==undefined);
1013+
conststreamState=stream[kState];
1014+
const{
1015+
controller,
1016+
}=streamState;
1017+
constchunkSize=writableStreamDefaultControllerGetChunkSize(
1018+
controller,
1019+
chunk);
1020+
if(stream!==writerState.stream){
1021+
request.failed=true;
1022+
request.failure=
1023+
newERR_INVALID_STATE.TypeError('Mismatched WritableStreams');
1024+
return;
1025+
}
1026+
const{
1027+
state,
1028+
}=streamState;
1029+
1030+
if(state==='errored'){
1031+
request.failed=true;
1032+
request.failure=streamState.storedError;
1033+
return;
1034+
}
1035+
1036+
if(streamState.closeQueuedOrInFlight||state==='closed'){
1037+
request.failed=true;
1038+
request.failure=
1039+
newERR_INVALID_STATE.TypeError('WritableStream is closed');
1040+
return;
1041+
}
1042+
1043+
if(state==='erroring'){
1044+
request.failed=true;
1045+
request.failure=streamState.storedError;
1046+
return;
1047+
}
1048+
1049+
assert(state==='writable');
1050+
1051+
letwriteRequests=streamState.writeRequests;
1052+
if(writeRequests===kEmptyQueue)
1053+
writeRequests=streamState.writeRequests=newQueue();
1054+
writeRequests.push(request);
1055+
request.pending++;
1056+
writableStreamDefaultControllerWrite(controller,chunk,chunkSize);
1057+
}
1058+
10041059
functionwritableStreamDefaultWriterRelease(writer){
10051060
const{
10061061
stream,
@@ -1376,6 +1431,7 @@ module.exports = {
13761431
writableStreamCloseQueuedOrInFlight,
13771432
writableStreamAddWriteRequest,
13781433
writableStreamDefaultWriterWrite,
1434+
writableStreamDefaultWriterWriteWithRequest,
13791435
writableStreamDefaultWriterRelease,
13801436
writableStreamDefaultWriterGetDesiredSize,
13811437
writableStreamDefaultWriterEnsureReadyPromiseRejected,

0 commit comments

Comments
 (0)