Skip to content

Commit f302fbd

Browse files
jasnellrichardlau
authored andcommitted
stream: reduce per-chunk overhead in stream/iter consumers
bytes(), text(), arrayBuffer(), array() and their sync variants kept a snapshot object for every collected chunk, plus a batch entry and its views array, so they can detect chunks that were resized or detached before the result is assembled. For streams of many small chunks this dominated memory use: collecting 1,000,000 one-byte chunks peaked at about 490 MB of heap for 1 MB of data. A non-empty view of a fixed-length, non-shared ArrayBuffer can only change by its buffer being detached, which makes its byteLength 0, so recording its byteLength is enough. Keep a full snapshot only for empty views and views of resizable or shared buffers. The same input now peaks at about 60 MB and is collected about five times faster. Assisted-by: OpenCode Signed-off-by: James M Snell <jasnell@gmail.com> PR-URL: #66483 Reviewed-By: Trivikram Kamat <trivikr.dev@gmail.com>
1 parent 7ba60dc commit f302fbd

3 files changed

Lines changed: 98 additions & 35 deletions

File tree

‎lib/internal/streams/iter/consumers.js‎

Lines changed: 20 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,9 @@ const {
5454

5555
const {
5656
concatBytes,
57-
createBatchEntry,
5857
getProtocolMethod,
59-
validateBatchEntry,
58+
recordChunk,
59+
validateRecordedChunks,
6060
yieldAbortable,
6161
} = require('internal/streams/iter/utils');
6262

@@ -95,17 +95,6 @@ function isMergeOptions(value) {
9595
// Shared chunk collection helpers
9696
// =============================================================================
9797

98-
function flattenBatchEntries(entries) {
99-
const chunks = [];
100-
for (let i = 0; i < entries.length; i++) {
101-
const batch = validateBatchEntry(entries[i]);
102-
for (let j = 0; j < batch.length; j++) {
103-
ArrayPrototypePush(chunks, batch[j]);
104-
}
105-
}
106-
return chunks;
107-
}
108-
10998
/**
11099
* Collect chunks from a sync source into an array.
111100
* @param {Iterable<Uint8Array[]>} source
@@ -115,23 +104,20 @@ function flattenBatchEntries(entries) {
115104
function collectSync(source, limit) {
116105
// Normalize source via fromSync() - accepts strings, ArrayBuffers, protocols, etc.
117106
const normalized = fromSync(source);
118-
const entries = [];
107+
const chunks = [];
108+
const checks = [];
119109
let totalBytes = 0;
120110

121111
for (const batch of normalized) {
122-
const entry = createBatchEntry(batch);
123-
if (limit !== undefined) {
124-
for (let i = 0; i < entry.views.length; i++) {
125-
totalBytes += entry.views[i].byteLength;
126-
if (totalBytes > limit) {
127-
throw new ERR_OUT_OF_RANGE('totalBytes', `<= ${limit}`, totalBytes);
128-
}
112+
for (let i = 0; i < batch.length; i++) {
113+
totalBytes += recordChunk(chunks, checks, batch[i]);
114+
if (limit !== undefined && totalBytes > limit) {
115+
throw new ERR_OUT_OF_RANGE('totalBytes', `<= ${limit}`, totalBytes);
129116
}
130117
}
131-
ArrayPrototypePush(entries, entry);
132118
}
133119

134-
return flattenBatchEntries(entries);
120+
return validateRecordedChunks(chunks, checks);
135121
}
136122

137123
/**
@@ -146,14 +132,17 @@ async function collectAsync(source, signal, limit) {
146132

147133
// Normalize source via from() - accepts strings, ArrayBuffers, protocols, etc.
148134
const normalized = from(source);
149-
const entries = [];
135+
const chunks = [];
136+
const checks = [];
150137

151138
// Fast path: no signal and no limit
152139
if (!signal && limit === undefined) {
153140
for await (const batch of normalized) {
154-
ArrayPrototypePush(entries, createBatchEntry(batch));
141+
for (let i = 0; i < batch.length; i++) {
142+
recordChunk(chunks, checks, batch[i]);
143+
}
155144
}
156-
return flattenBatchEntries(entries);
145+
return validateRecordedChunks(chunks, checks);
157146
}
158147

159148
// Slow path: with signal or limit checks
@@ -162,19 +151,15 @@ async function collectAsync(source, signal, limit) {
162151

163152
for await (const batch of iterable) {
164153
signal?.throwIfAborted();
165-
const entry = createBatchEntry(batch);
166-
if (limit !== undefined) {
167-
for (let i = 0; i < entry.views.length; i++) {
168-
totalBytes += entry.views[i].byteLength;
169-
if (totalBytes > limit) {
170-
throw new ERR_OUT_OF_RANGE('totalBytes', `<= ${limit}`, totalBytes);
171-
}
154+
for (let i = 0; i < batch.length; i++) {
155+
totalBytes += recordChunk(chunks, checks, batch[i]);
156+
if (limit !== undefined && totalBytes > limit) {
157+
throw new ERR_OUT_OF_RANGE('totalBytes', `<= ${limit}`, totalBytes);
172158
}
173159
}
174-
ArrayPrototypePush(entries, entry);
175160
}
176161

177-
return flattenBatchEntries(entries);
162+
return validateRecordedChunks(chunks, checks);
178163
}
179164

180165
/**

‎lib/internal/streams/iter/utils.js‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const {
44
Array,
55
ArrayBufferPrototypeGetByteLength,
66
ArrayBufferPrototypeGetDetached,
7+
ArrayBufferPrototypeGetResizable,
78
ArrayPrototypePush,
89
ArrayPrototypeSlice,
910
PromiseResolve,
@@ -235,6 +236,51 @@ function validateByteView(snapshot) {
235236
return value;
236237
}
237238

239+
/**
240+
* Append a chunk to `chunks` for later concatenation, and the information
241+
* needed to detect that it was resized or detached in the meantime to
242+
* `checks`. This provides the same guarantee as snapshotByteView() and
243+
* validateByteView() without allocating a snapshot for every chunk in the
244+
* common case: a non-empty view of a fixed-length, non-shared ArrayBuffer can
245+
* only change by the buffer being detached, which makes its byteLength 0, so
246+
* its byteLength is all that needs to be recorded.
247+
* @param {Uint8Array[]} chunks
248+
* @param {Array<number|object>} checks
249+
* @param {Uint8Array} value
250+
* @returns {number} The byteLength of `value`.
251+
*/
252+
function recordChunk(chunks, checks, value) {
253+
const buffer = TypedArrayPrototypeGetBuffer(value);
254+
const byteLength = TypedArrayPrototypeGetByteLength(value);
255+
ArrayPrototypePush(chunks, value);
256+
if (byteLength === 0 || isSharedArrayBuffer(buffer) ||
257+
ArrayBufferPrototypeGetResizable(buffer)) {
258+
ArrayPrototypePush(checks, snapshotByteView(value));
259+
} else {
260+
ArrayPrototypePush(checks, byteLength);
261+
}
262+
return byteLength;
263+
}
264+
265+
/**
266+
* Validate chunks recorded with recordChunk().
267+
* @param {Uint8Array[]} chunks
268+
* @param {Array<number|object>} checks
269+
* @returns {Uint8Array[]} `chunks`
270+
*/
271+
function validateRecordedChunks(chunks, checks) {
272+
for (let i = 0; i < chunks.length; i++) {
273+
const check = checks[i];
274+
if (typeof check !== 'number') {
275+
validateByteView(check);
276+
} else if (TypedArrayPrototypeGetByteLength(chunks[i]) !== check) {
277+
throw new ERR_INVALID_STATE.TypeError(
278+
'Byte view was resized or detached after being accepted');
279+
}
280+
}
281+
return chunks;
282+
}
283+
238284
function createBatchEntry(chunks) {
239285
const views = new Array(chunks.length);
240286
let byteLength = 0;
@@ -489,6 +535,7 @@ module.exports = {
489535
concatBytes,
490536
convertChunks,
491537
createBatchEntry,
538+
recordChunk,
492539
splitBatchEntry,
493540
getProtocolMethod,
494541
getWriterSignal,
@@ -503,6 +550,7 @@ module.exports = {
503550
toWriterUint8Array,
504551
validateBackpressure,
505552
validateBatchEntry,
553+
validateRecordedChunks,
506554
validateByteView,
507555
yieldAbortable,
508556
};

‎test/parallel/test-stream-iter-resizable-buffers.js‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,12 +167,42 @@ async function testPipeRejectsWriterResize() {
167167
);
168168
}
169169

170+
async function testConsumersRejectDetachedViews() {
171+
// Views of fixed-length buffers are tracked without a full snapshot; they
172+
// must still be rejected when detached after being accepted.
173+
const asyncBuffer = new ArrayBuffer(2);
174+
async function* asyncSource() {
175+
yield [new Uint8Array(asyncBuffer), Uint8Array.of(1)];
176+
asyncBuffer.transfer();
177+
}
178+
await assert.rejects(array(asyncSource()), kResizeError);
179+
const limitedBuffer = new ArrayBuffer(2);
180+
async function* limitedSource() {
181+
yield [new Uint8Array(limitedBuffer)];
182+
limitedBuffer.transfer();
183+
}
184+
await assert.rejects(array(limitedSource(), { limit: 10 }), kResizeError);
185+
186+
const syncBuffer = new ArrayBuffer(2);
187+
function* syncSource() {
188+
yield [new Uint8Array(syncBuffer, 1)];
189+
syncBuffer.transfer();
190+
}
191+
assert.throws(() => arraySync(syncSource()), kResizeError);
192+
193+
// Unchanged views are returned as-is.
194+
const chunk = new Uint8Array(4);
195+
const [result] = arraySync([[chunk]]);
196+
assert.strictEqual(result, chunk);
197+
}
198+
170199
Promise.all([
171200
testBufferedViewMutationRejected(),
172201
testDropOldestUsesAcceptedByteLength(),
173202
testPendingWritesRejectResizedViews(),
174203
testBroadcastRejectsResizedBufferedView(),
175204
testShareRejectsResizedBufferedView(),
176205
testConsumersRejectResizedViews(),
206+
testConsumersRejectDetachedViews(),
177207
testPipeRejectsWriterResize(),
178208
]).then(common.mustCall());

0 commit comments

Comments
 (0)