Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
stream: speed up flowing pipe of buffers
flow() pulls one already-buffered chunk and calls _read() for the
next one on every iteration. That goes through the general read()
path, which updates a holey buffer array and then pulls the chunk
back out.

While a synchronous byte-mode flow is in progress, keep that
prefetched chunk on the readable state and emit it directly.
_read() of the next chunk still runs before 'data', and a nested
read() moves the chunk back onto the buffer.

benchmark/streams/pipe.js is about 77% faster (15 runs).
pipe-object-mode, readable-readall, and readable-bigread stay
within noise.

Assisted-by: a closed-source coding agent
Signed-off-by: Yagiz Nizipli <yagiz@nizipli.com>
  • Loading branch information
anonrig committed Sep 21, 2026
commit 8fee6fafaf70c7bed73fc20ac7c471d677148f17
1 change: 1 addition & 0 deletions lib/internal/streams/destroy.js
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ function undestroy() {
r.errored = null;
r.errorEmitted = false;
r.reading = false;
r.fastChunk = null;
r.ended = r.readable === false;
r.endEmitted = r.readable === false;
}
Expand Down
127 changes: 125 additions & 2 deletions lib/internal/streams/readable.js
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,9 @@ const kPaused = 1 << 26;
const kDataListening = 1 << 27;
const kEndScheduled = 1 << 28;
const kEofReadablePending = 1 << 29;
// Set only while flowSync() is inside _read(). push() then keeps the
// chunk on state.fastChunk instead of the buffer array.
const kFastPush = 1 << 30;

// TODO(benjamingr) it is likely slower to do it this way than with free functions
function makeBitMapDescriptor(bit) {
Expand Down Expand Up @@ -296,6 +299,8 @@ function ReadableState(options, stream, isDuplex) {
this.buffer = [];
this.bufferIndex = 0;
this.length = 0;
// Chunk prefetched by flowSync(), kept off the buffer array.
this.fastChunk = null;
this.pipes = [];

// Should close be emitted on destroy. Defaults to true.
Expand Down Expand Up @@ -400,9 +405,29 @@ Readable.prototype[SymbolAsyncDispose] = async function() {
// similar to how Writable.write() returns true if you should
// write() some more.
Readable.prototype.push = function(chunk, encoding) {
const state = this._readableState;

// flowSync() is inside _read() and wants a single buffer parked on
// fastChunk. A second push, a string, or EOF drops back to the buffer.
if ((state[kState] & kFastPush) !== 0 && encoding == null &&
state.fastChunk == null && chunk instanceof Buffer && chunk.length > 0) {
Comment thread
anonrig marked this conversation as resolved.
state.fastChunk = chunk;
state.length = chunk.length;
state[kState] &= ~kReading;
return chunk.length < state.highWaterMark;
}
if ((state[kState] & kFastPush) !== 0) {
state[kState] &= ~kFastPush;
if (state.fastChunk != null) {
const first = state.fastChunk;
state.fastChunk = null;
state.length = 0;
readableAddChunkPushByteMode(this, state, first);
}
}

debug('push', chunk);

const state = this._readableState;
return (state[kState] & kObjectMode) === 0 ?
readableAddChunkPushByteMode(this, state, chunk, encoding) :
readableAddChunkPushObjectMode(this, state, chunk, encoding);
Expand Down Expand Up @@ -601,6 +626,8 @@ Readable.prototype.isPaused = function() {
// Backwards compatibility.
Readable.prototype.setEncoding = function(enc) {
const state = this._readableState;
if (state.fastChunk != null)
materializeFastChunk(state);

const decoder = new StringDecoder(enc);
state.decoder = decoder;
Expand Down Expand Up @@ -667,6 +694,11 @@ function howMuchToRead(n, state) {

// You can override either this method, or the async _read(n) below.
Readable.prototype.read = function(n) {
// A nested read() during flowSync()'s 'data' event must see the
// prefetched chunk. Null for every read that is not inside that loop.
if (this._readableState.fastChunk != null)
materializeFastChunk(this._readableState);

debug('read', n);
// Same as parseInt(undefined, 10), however V8 7.3 performance regressed
// in this scenario, so we are doing it manually.
Expand Down Expand Up @@ -1336,9 +1368,91 @@ Readable.prototype.pause = function() {
function flow(stream) {
const state = stream._readableState;
debug('flow');
// Byte-mode pipe sits in read() to pull one already-buffered chunk and
// refill. That read is most of the per-chunk cost. flowSync() keeps the
// same prefetch order without the buffer array or the general read path.
if (flowSync(stream, state))
return;
while ((state[kState] & kFlowing) !== 0 && stream.read() !== null);
}

const kFastFlowNeed = kConstructed | kFlowing | kDataListening;
const kFastFlowBlock = kObjectMode | kDecoder | kEnded | kDestroyed |
kErrored | kPaused | kReading | kSync;

// Returns true when this call owned the flowing loop, including any
// fallback to read() after the fast path stops.
function flowSync(stream, state) {
const bits = state[kState];
if ((bits & kFastFlowNeed) !== kFastFlowNeed ||
(bits & kFastFlowBlock) !== 0 ||
!(state.highWaterMark > 0) ||
state.fastChunk != null) {
return false;
}

if (state.length !== 0) {
const buf = state.buffer;
const idx = state.bufferIndex;
const chunk = buf[idx];
// Only the one-chunk prefetch left by read(0) / the previous read.
if (buf.length !== idx + 1 || chunk == null || chunk.length !== state.length)
return false;
buf.length = 0;
state.bufferIndex = 0;
state.fastChunk = chunk;
}

while ((state[kState] & kFlowing) !== 0) {
const current = state.fastChunk;
if (current == null)
break;
if ((state[kState] & kFastFlowBlock) !== 0)
break;

// _read() of the next chunk runs before 'data', matching read().
state.fastChunk = null;
state.length = 0;
state[kState] |= kReading | kSync | kFastPush;
try {
stream._read(state.highWaterMark);
} catch (err) {
state[kState] &= ~(kSync | kFastPush);
errorOrDestroy(stream, err);
break;
}
state[kState] &= ~(kSync | kFastPush);

if ((state[kState] & (kErrorEmitted | kCloseEmitted)) === 0) {
state[kState] |= kDataEmitted;
stream.emit('data', current);
}

// Nested read() moved fastChunk into the buffer and may have refilled.
if (state.fastChunk == null && state.length !== 0)
break;
}

if (state.fastChunk != null)
materializeFastChunk(state);

while ((state[kState] & kFlowing) !== 0 && stream.read() !== null);
return true;
}

// Put fastChunk at the head of the buffer. state.length already counts it.
function materializeFastChunk(state) {
const chunk = state.fastChunk;
if (chunk == null)
return;
state.fastChunk = null;
if (state.bufferIndex > 0) {
state.buffer[--state.bufferIndex] = chunk;
} else {
state.buffer.unshift(chunk);
}
}

// Wrap an old-style stream as the async data source.
// This is *not* part of the readable stream interface.
// It is an ugly unfortunate mess of history.
Expand Down Expand Up @@ -1724,7 +1838,16 @@ ObjectDefineProperties(Readable.prototype, {
__proto__: null,
enumerable: false,
get: function() {
return this._readableState?.buffer;
const state = this._readableState;
if (state == null)
return undefined;
if (state.fastChunk == null)
return state.buffer;
if (state.bufferIndex === state.buffer.length)
return [state.fastChunk];
const out = state.buffer.slice(state.bufferIndex);
out.push(state.fastChunk);
return out;
},
},

Expand Down
Loading