Skip to content

Commit c01723a

Browse files
committed
stream: use ring buffer for WHATWG stream queues
The [[queue]] backing every default readable/writable controller was a plain array of { value, size } wrappers consumed with ArrayPrototypeShift, so each buffered chunk allocated a wrapper object and each dequeue moved (or forced the engine to re-linearize) the remaining elements; the byte controller queue paid the same shift cost for its chunk descriptor records. Replace the array with a power-of-two ring buffer. Default controller queues store each entry as (value, size) in two consecutive slots, so the per-chunk wrapper allocation disappears; the byte controller keeps its descriptor records (they are mutated in place at the head) in single slots. Controllers start from (and are reset to) a shared immutable empty queue, so constructing a stream allocates no queue storage until a chunk is actually buffered. Enqueues measured by the internal default size algorithm (never observable by user code, always returns 1, cannot throw) skip the algorithm call and its try/catch entirely. The layout mirrors what Bun/WebKit use for the same spec structure: [[queue]] as a ring-buffer deque (WTF::Deque in Bun's src/jsc/bindings/webcore/streams/StreamQueue.h), the pure-JS ring buffer in Bun's src/js/internal/fifo.ts, and the trivial-size-algorithm bypass in src/jsc/bindings/webcore/streams/JSReadableStreamDefaultController.cpp. benchmark/compare.js against the unmodified baseline (30-run capture plus an independent 15-run repeat, Welch t-test, all p < 1e-5): webstreams/pipe-to.js +12-17% across all sixteen high-water-mark configurations, readable-read-buffered +20% (bufferSize=1) to +49% (bufferSize=1000), readable-async-iterator +21%. No stable significant regression across the rest of the webstreams suite: the creation.js and readable-read.js deltas seen in the full-suite capture disappear in isolated 60-run rechecks. Refs: https://github.com/oven-sh/bun/blob/main/src/js/internal/fifo.ts Signed-off-by: Yagiz Nizipli <yagiz@nizipli.com>
1 parent 6f77105 commit c01723a

4 files changed

Lines changed: 352 additions & 25 deletions

File tree

lib/internal/webstreams/readablestream.js

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -113,9 +113,11 @@ const {
113113
extractSizeAlgorithm,
114114
getNonWritablePropertyDescriptor,
115115
isBrandCheck,
116+
kEmptyQueue,
116117
kState,
117118
kType,
118119
lazyTransfer,
120+
materializeQueue,
119121
nonOpCancel,
120122
nonOpPull,
121123
nonOpStart,
@@ -2495,6 +2497,11 @@ function readableStreamDefaultControllerEnqueue(controller, chunk) {
24952497
reader[kType] === 'ReadableStreamDefaultReader' &&
24962498
reader[kState].readRequests.length) {
24972499
readableStreamFulfillReadRequest(stream, chunk, false);
2500+
} else if (controllerState.sizeAlgorithm === defaultSizeAlgorithm) {
2501+
// The internal default size algorithm is never observable by user
2502+
// code, always returns 1, and cannot throw: enqueue with the
2503+
// constant instead of calling it.
2504+
enqueueValueWithSize(controller, chunk, 1);
24982505
} else {
24992506
try {
25002507
const chunkSize =
@@ -2652,7 +2659,7 @@ function setupReadableStreamDefaultController(
26522659
pulling: false,
26532660
pullFulfilled: undefined,
26542661
pullRejected: undefined,
2655-
queue: [],
2662+
queue: kEmptyQueue,
26562663
queueTotalSize: 0,
26572664
started: false,
26582665
sizeAlgorithm,
@@ -3112,14 +3119,13 @@ function readableByteStreamControllerEnqueueChunkToQueue(
31123119
buffer,
31133120
byteOffset,
31143121
byteLength) {
3115-
ArrayPrototypePush(
3116-
controller[kState].queue,
3117-
{
3118-
buffer,
3119-
byteOffset,
3120-
byteLength,
3121-
});
3122-
controller[kState].queueTotalSize += byteLength;
3122+
const state = controller[kState];
3123+
materializeQueue(state).push({
3124+
buffer,
3125+
byteOffset,
3126+
byteLength,
3127+
});
3128+
state.queueTotalSize += byteLength;
31233129
}
31243130

31253131
function readableByteStreamControllerEnqueueDetachedPullIntoToQueue(
@@ -3174,7 +3180,7 @@ function readableByteStreamControllerFillPullIntoDescriptorFromQueue(
31743180
} = controller[kState];
31753181

31763182
while (totalBytesToCopyRemaining) {
3177-
const headOfQueue = queue[0];
3183+
const headOfQueue = queue.peek();
31783184
const bytesToCopy = MathMin(
31793185
totalBytesToCopyRemaining,
31803186
headOfQueue.byteLength);
@@ -3192,7 +3198,7 @@ function readableByteStreamControllerFillPullIntoDescriptorFromQueue(
31923198
headOfQueue.byteOffset,
31933199
bytesToCopy);
31943200
if (headOfQueue.byteLength === bytesToCopy) {
3195-
ArrayPrototypeShift(queue);
3201+
queue.shift();
31963202
} else {
31973203
headOfQueue.byteOffset += bytesToCopy;
31983204
headOfQueue.byteLength -= bytesToCopy;
@@ -3405,7 +3411,7 @@ function readableByteStreamControllerFillReadRequestFromQueue(controller, readRe
34053411
buffer,
34063412
byteOffset,
34073413
byteLength,
3408-
} = ArrayPrototypeShift(queue);
3414+
} = queue.shift();
34093415

34103416
controller[kState].queueTotalSize -= byteLength;
34113417
readableByteStreamControllerHandleQueueDrain(controller);
@@ -3498,7 +3504,7 @@ function setupReadableByteStreamController(
34983504
pullRejected: undefined,
34993505
started: false,
35003506
stream,
3501-
queue: [],
3507+
queue: kEmptyQueue,
35023508
queueTotalSize: 0,
35033509
highWaterMark,
35043510
pullAlgorithm,

lib/internal/webstreams/util.js

Lines changed: 148 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
'use strict';
22

33
const {
4+
Array,
45
ArrayBufferPrototypeGetByteLength,
56
ArrayBufferPrototypeGetDetached,
67
ArrayBufferPrototypeSlice,
7-
ArrayPrototypePush,
8-
ArrayPrototypeShift,
98
AsyncIteratorPrototype,
109
FunctionPrototypeCall,
1110
MathMax,
1211
NumberIsNaN,
12+
ObjectFreeze,
1313
PromisePrototypeThen,
1414
PromiseReject,
1515
PromiseResolve,
@@ -134,32 +134,167 @@ function isBrandCheck(brand) {
134134
};
135135
}
136136

137+
// Backing store for the spec's [[queue]]: a power-of-two ring buffer
138+
// instead of a plain array. Entries are pushed at the tail and consumed
139+
// at the head, so a plain array either moves every element or forces the
140+
// engine to re-linearize on each shift, and the default controllers would
141+
// additionally have to allocate a { value, size } wrapper object per
142+
// chunk just to keep the pair together. Default readable/writable
143+
// controller queues store each entry as (value, size) in two consecutive
144+
// slots via the *Pair methods; the readable byte controller queue stores
145+
// its chunk descriptor records in single slots via push/shift/peek. A
146+
// given instance only ever uses one of the two access patterns, so
147+
// head/tail stay aligned to the entry stride.
148+
class Queue {
149+
constructor(listLength = 8) {
150+
this.head = 0;
151+
this.tail = 0;
152+
// Number of logical entries currently in the queue: (value, size)
153+
// pairs for default controller queues, descriptor records for byte
154+
// controller queues.
155+
this.length = 0;
156+
this.capacityMask = listLength - 1;
157+
this.list = new Array(listLength);
158+
this.dequeuedSize = 0;
159+
}
160+
161+
// Single-slot entries (readable byte controller chunk records).
162+
163+
push(entry) {
164+
const tail = this.tail;
165+
this.list[tail] = entry;
166+
this.tail = (tail + 1) & this.capacityMask;
167+
this.length++;
168+
if (this.tail === this.head)
169+
this.grow();
170+
}
171+
172+
shift() {
173+
const head = this.head;
174+
const list = this.list;
175+
const entry = list[head];
176+
list[head] = undefined;
177+
this.head = (head + 1) & this.capacityMask;
178+
if (--this.length === 0)
179+
this.rewind();
180+
return entry;
181+
}
182+
183+
peek() {
184+
return this.list[this.head];
185+
}
186+
187+
// Two-slot (value, size) entries (default controller queues). The
188+
// stride is always 2 and capacities are even, so `tail + 1`/`head + 1`
189+
// never need to wrap.
190+
191+
pushPair(value, size) {
192+
const tail = this.tail;
193+
const list = this.list;
194+
list[tail] = value;
195+
list[tail + 1] = size;
196+
this.tail = (tail + 2) & this.capacityMask;
197+
this.length++;
198+
if (this.tail === this.head)
199+
this.grow();
200+
}
201+
202+
// Returns the dequeued value; the size of the same entry is left in
203+
// `this.dequeuedSize` so that callers can update [[queueTotalSize]]
204+
// without a per-entry wrapper object having to exist.
205+
shiftPair() {
206+
const head = this.head;
207+
const list = this.list;
208+
const value = list[head];
209+
this.dequeuedSize = list[head + 1];
210+
list[head] = undefined;
211+
list[head + 1] = undefined;
212+
this.head = (head + 2) & this.capacityMask;
213+
if (--this.length === 0)
214+
this.rewind();
215+
return value;
216+
}
217+
218+
peekPairValue() {
219+
return this.list[this.head];
220+
}
221+
222+
// The ring is completely full (the post-push tail caught up with the
223+
// head): double the capacity, re-linearizing from the head so index
224+
// arithmetic stays trivial.
225+
grow() {
226+
const list = this.list;
227+
const capacity = list.length;
228+
const head = this.head;
229+
if (head !== 0) {
230+
const relinearized = new Array(capacity * 2);
231+
let n = 0;
232+
for (let i = head; i < capacity; i++)
233+
relinearized[n++] = list[i];
234+
for (let i = 0; i < head; i++)
235+
relinearized[n++] = list[i];
236+
this.list = relinearized;
237+
this.head = 0;
238+
} else {
239+
list.length = capacity * 2;
240+
}
241+
this.tail = capacity;
242+
this.capacityMask = (capacity * 2) - 1;
243+
}
244+
245+
// The queue just became empty: restart at slot 0 so shallow queues net
246+
// sequential slot access, and drop the enlarged backing store after a
247+
// large burst has fully drained.
248+
rewind() {
249+
this.head = 0;
250+
this.tail = 0;
251+
if (this.list.length > 1024) {
252+
this.list.length = 8;
253+
this.capacityMask = 0b111;
254+
}
255+
}
256+
}
257+
258+
// Controllers start out with (and are reset to) this shared immutable
259+
// empty queue, so constructing a stream never allocates queue storage;
260+
// a real Queue is materialized by the enqueue paths on first use. All
261+
// dequeue/peek paths are guarded by `.length` (or the equivalent
262+
// [[queueTotalSize]]) checks, so they can never observe the sentinel in
263+
// a mutating way; it never stores entries, so it gets a zero-length
264+
// backing list.
265+
const kEmptyQueue = ObjectFreeze(new Queue(0));
266+
267+
function materializeQueue(state) {
268+
const queue = state.queue;
269+
if (queue === kEmptyQueue)
270+
return state.queue = new Queue();
271+
return queue;
272+
}
273+
137274
// The queue helpers below run once per chunk on the hot paths of every
138275
// default readable/writable stream, so they load the controller state a
139276
// single time and don't assert the existence of the queue fields (both
140277
// are unconditionally initialized during controller setup and only ever
141278
// replaced wholesale).
142279
function dequeueValue(controller) {
143280
const state = controller[kState];
144-
assert(state.queue.length);
145-
const {
146-
value,
147-
size,
148-
} = ArrayPrototypeShift(state.queue);
149-
state.queueTotalSize = MathMax(0, state.queueTotalSize - size);
281+
const queue = state.queue;
282+
assert(queue.length);
283+
const value = queue.shiftPair();
284+
state.queueTotalSize = MathMax(0, state.queueTotalSize - queue.dequeuedSize);
150285
return value;
151286
}
152287

153288
function resetQueue(controller) {
154289
const state = controller[kState];
155-
state.queue = [];
290+
state.queue = kEmptyQueue;
156291
state.queueTotalSize = 0;
157292
}
158293

159294
function peekQueueValue(controller) {
160295
const state = controller[kState];
161296
assert(state.queue.length);
162-
return state.queue[0].value;
297+
return state.queue.peekPairValue();
163298
}
164299

165300
function enqueueValueWithSize(controller, value, size) {
@@ -170,7 +305,7 @@ function enqueueValueWithSize(controller, value, size) {
170305
coercedSize === Infinity) {
171306
throw new ERR_INVALID_ARG_VALUE.RangeError('size', size);
172307
}
173-
ArrayPrototypePush(state.queue, { value, size: coercedSize });
308+
materializeQueue(state).pushPair(value, coercedSize);
174309
state.queueTotalSize += coercedSize;
175310
}
176311

@@ -266,9 +401,11 @@ module.exports = {
266401
getNonWritablePropertyDescriptor,
267402
isBrandCheck,
268403
isPromisePending,
404+
kEmptyQueue,
269405
kState,
270406
kType,
271407
lazyTransfer,
408+
materializeQueue,
272409
nonOpCancel,
273410
nonOpFlush,
274411
nonOpPull,

lib/internal/webstreams/writablestream.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ const {
6767
getNonWritablePropertyDescriptor,
6868
isBrandCheck,
6969
isPromisePending,
70+
kEmptyQueue,
7071
kState,
7172
kType,
7273
lazyTransfer,
@@ -1177,6 +1178,11 @@ function writableStreamDefaultControllerGetChunkSize(controller, chunk) {
11771178
return 1;
11781179
}
11791180

1181+
// The internal default size algorithm is never observable by user
1182+
// code, always returns 1, and cannot throw: skip the call.
1183+
if (sizeAlgorithm === defaultSizeAlgorithm)
1184+
return 1;
1185+
11801186
try {
11811187
return FunctionPrototypeCall(
11821188
sizeAlgorithm,
@@ -1293,7 +1299,7 @@ function setupWritableStreamDefaultController(
12931299
abortAlgorithm,
12941300
closeAlgorithm,
12951301
highWaterMark,
1296-
queue: [],
1302+
queue: kEmptyQueue,
12971303
queueTotalSize: 0,
12981304
abortController: new AbortController(),
12991305
sizeAlgorithm,

0 commit comments

Comments
 (0)