Skip to content

Commit bf7c279

Browse files
committed
stream: trim webstreams sink wrappers and BYOB transfers
The close and abort methods of an underlying sink and the flush and cancel methods of a transformer were each wrapped at construction in an async closure that the controller kept for the stream's lifetime: two per WritableStream and two per TransformStream, each with its context. Store these one-shot methods as-is in the controller state next to the object they belong to, and call them as that object's methods with the spec's argument list through a promiseCall helper that keeps the reference implementation's adoption ticks. The per-chunk methods (write, transform) keep their closures, which make the method a constant for the optimizing compiler on the hot paths; so does the source's cancel wrapper, since the readable stream's construction path is sensitive to any change in its inlining budget. In the byte controller, the transfer of a pull-into descriptor's buffer when it is committed is dropped: nothing can reach that buffer any more, since it was transferred when the descriptor was created and the BYOB request that exposed it is only ever invalidated together with a transfer. The transfer of the first pending descriptor's buffer on enqueue is likewise only needed while a BYOB request exposes it. Allocating an ArrayBuffer costs about half a microsecond, so those two transfers were a large part of a BYOB read. Bytes allocated per stream with user methods: WritableStream 1040 -> 844, TransformStream 1816 -> 1720. The microtask ordering of every user method call is unchanged. Benchmark results (20 runs, vs. the previous commit): webstreams/readable-read.js type='byob' n=100000 *** 17.14 % ±1.80% webstreams/readable-async-iterator.js type='normal' n=100000 3.79 % ±4.49% webstreams/from.js kind='sync' n=1000000 * -2.18 % ±2.02% webstreams/js_transfer.js n=10000 payload='TransformStream' * -1.85 % ±1.58% The other 42 rows are within their confidence intervals; the two rows at the 5% level are the expected number of false positives for 46 comparisons. Signed-off-by: Matteo Collina <hello@matteocollina.com>
1 parent eb663fe commit bf7c279

5 files changed

Lines changed: 257 additions & 70 deletions

File tree

‎lib/internal/webstreams/readablestream.js‎

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2388,13 +2388,16 @@ function readableByteStreamControllerConvertPullIntoDescriptor(desc) {
23882388
if (bytesFilled > byteLength)
23892389
throw new ERR_INVALID_STATE.RangeError('The buffer size is invalid');
23902390
assert(!(bytesFilled % elementSize));
2391-
const transferredBuffer = ArrayBufferPrototypeTransfer(buffer);
2391+
// The spec transfers the buffer once more here. Nothing else can reach
2392+
// it by now: it was transferred when the descriptor was created, and
2393+
// the BYOB request that exposes it has been invalidated, which always
2394+
// goes together with a transfer, so the view is built on it as-is.
23922395

23932396
if (ctor === Buffer) {
2394-
return Buffer.from(transferredBuffer, byteOffset, bytesFilled / elementSize);
2397+
return Buffer.from(buffer, byteOffset, bytesFilled / elementSize);
23952398
}
23962399

2397-
return new ctor(transferredBuffer, byteOffset, bytesFilled / elementSize);
2400+
return new ctor(buffer, byteOffset, bytesFilled / elementSize);
23982401
}
23992402

24002403
function isReadableStreamLocked(stream) {
@@ -3427,11 +3430,15 @@ function readableByteStreamControllerEnqueue(controller, chunk) {
34273430
);
34283431
}
34293432

3430-
readableByteStreamControllerInvalidateBYOBRequest(controller);
3431-
3432-
firstPendingPullInto.buffer = ArrayBufferPrototypeTransfer(
3433-
firstPendingPullInto.buffer,
3434-
);
3433+
// The transfer detaches the view the BYOB request exposed; without
3434+
// a request the buffer is unreachable and there is nothing to
3435+
// detach.
3436+
if (controller[kState].byobRequest !== null) {
3437+
readableByteStreamControllerInvalidateBYOBRequest(controller);
3438+
firstPendingPullInto.buffer = ArrayBufferPrototypeTransfer(
3439+
firstPendingPullInto.buffer,
3440+
);
3441+
}
34353442

34363443
if (firstPendingPullInto.type === 'none') {
34373444
readableByteStreamControllerEnqueueDetachedPullIntoToQueue(

‎lib/internal/webstreams/transformstream.js‎

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ const {
3535
} = require('internal/util');
3636

3737
const {
38+
validateFunction,
3839
validateObject,
3940
kValidateObjectAllowObjects,
4041
kValidateObjectAllowObjectsAndNull,
@@ -48,7 +49,6 @@ const {
4849
} = require('internal/worker/js_transferable');
4950

5051
const {
51-
createPromiseCallback1Param,
5252
createRawCallback2Params,
5353
customInspect,
5454
deferredStartAlgorithm,
@@ -60,8 +60,8 @@ const {
6060
kResolvedPromise,
6161
kState,
6262
kType,
63-
nonOpCancel,
64-
nonOpFlush,
63+
nonOpCallback,
64+
promiseCall1Param,
6565
} = require('internal/webstreams/util');
6666

6767
const {
@@ -273,6 +273,9 @@ ObjectSetPrototypeOf(TransformStreamState.prototype, null);
273273

274274
class TransformStreamControllerState {
275275
stream = undefined;
276+
// The user-provided transformer the flush and cancel algorithms are
277+
// methods of.
278+
transformer = undefined;
276279
transformAlgorithm = undefined;
277280
flushAlgorithm = undefined;
278281
cancelAlgorithm = undefined;
@@ -517,11 +520,13 @@ function setupTransformStreamDefaultController(
517520
controller,
518521
transformAlgorithm,
519522
flushAlgorithm,
520-
cancelAlgorithm) {
523+
cancelAlgorithm,
524+
transformer) {
521525
assert(isTransformStream(stream));
522526
assert(stream[kState].controller === undefined);
523527
const controllerState = new TransformStreamControllerState();
524528
controllerState.stream = stream;
529+
controllerState.transformer = transformer;
525530
controllerState.transformAlgorithm = transformAlgorithm;
526531
controllerState.flushAlgorithm = flushAlgorithm;
527532
controllerState.cancelAlgorithm = cancelAlgorithm;
@@ -539,25 +544,25 @@ function setupTransformStreamDefaultControllerFromTransformer(
539544
const transformAlgorithm = transform ?
540545
createRawCallback2Params('transformer.transform', transform, transformer) :
541546
defaultTransformAlgorithm;
542-
const flushAlgorithm = flush ?
543-
createPromiseCallback1Param('transformer.flush', flush, transformer) :
544-
nonOpFlush;
545-
const cancelAlgorithm = cancel ?
546-
createPromiseCallback1Param('transformer.cancel', cancel, transformer) :
547-
nonOpCancel;
547+
if (flush)
548+
validateFunction(flush, 'transformer.flush');
549+
if (cancel)
550+
validateFunction(cancel, 'transformer.cancel');
548551

549552
setupTransformStreamDefaultController(
550553
stream,
551554
controller,
552555
transformAlgorithm,
553-
flushAlgorithm,
554-
cancelAlgorithm);
556+
flush || nonOpCallback,
557+
cancel || nonOpCallback,
558+
transformer);
555559
}
556560

557561
function transformStreamDefaultControllerClearAlgorithms(controller) {
558562
controller[kState].transformAlgorithm = undefined;
559563
controller[kState].flushAlgorithm = undefined;
560564
controller[kState].cancelAlgorithm = undefined;
565+
controller[kState].transformer = undefined;
561566
}
562567

563568
function transformStreamDefaultControllerEnqueue(controller, chunk) {
@@ -715,7 +720,9 @@ async function transformStreamDefaultSinkAbortAlgorithm(
715720

716721
const { promise, resolve, reject } = PromiseWithResolvers();
717722
controller[kState].finishPromise = promise;
718-
const cancelPromise = controller[kState].cancelAlgorithm(reason);
723+
const cancelPromise = promiseCall1Param(
724+
controller[kState].cancelAlgorithm, controller[kState].transformer,
725+
reason);
719726
transformStreamDefaultControllerClearAlgorithms(controller);
720727

721728
PromisePrototypeThen(
@@ -749,7 +756,9 @@ function transformStreamDefaultSinkCloseAlgorithm(writableController) {
749756
}
750757
const { promise, resolve, reject } = PromiseWithResolvers();
751758
controller[kState].finishPromise = promise;
752-
const flushPromise = controller[kState].flushAlgorithm(controller);
759+
const flushPromise = promiseCall1Param(
760+
controller[kState].flushAlgorithm, controller[kState].transformer,
761+
controller);
753762
transformStreamDefaultControllerClearAlgorithms(controller);
754763
PromisePrototypeThen(
755764
flushPromise,
@@ -796,7 +805,9 @@ function transformStreamDefaultSourceCancelAlgorithm(
796805

797806
const { promise, resolve, reject } = PromiseWithResolvers();
798807
controller[kState].finishPromise = promise;
799-
const cancelPromise = controller[kState].cancelAlgorithm(reason);
808+
const cancelPromise = promiseCall1Param(
809+
controller[kState].cancelAlgorithm, controller[kState].transformer,
810+
reason);
800811
transformStreamDefaultControllerClearAlgorithms(controller);
801812

802813
PromisePrototypeThen(

‎lib/internal/webstreams/util.js‎

Lines changed: 53 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -323,20 +323,14 @@ function enqueueValueWithSize(controller, value, size) {
323323
state.queueTotalSize += coercedSize;
324324
}
325325

326-
// Arity-specialized variants of the promise-callback wrapper. The generic
327-
// rest-parameter + ReflectApply form allocated an arguments array on every
328-
// invocation; these run on per-chunk hot paths (pull/write/transform), so
329-
// each known call-site arity gets its own wrapper. The exact number of
330-
// arguments passed through to the user callback is observable and must be
331-
// preserved.
332-
function createPromiseCallbackNoParams(name, fn, thisArg) {
333-
validateFunction(fn, name);
334-
return async () => FunctionPrototypeCall(fn, thisArg);
335-
}
336-
337-
// Raw variants that skip the async wrapper's implicit result promise.
338-
// Consumers of a raw callback invoke it inside try/catch and route the
339-
// result through thenAlgorithmResult() below.
326+
// The per-chunk callbacks of a user-provided source, sink or transformer
327+
// (pull, write, transform) are wrapped in a closure whose captured method
328+
// stays a constant for the optimizing compiler. Each known call-site
329+
// arity gets its own wrapper, since a rest-parameter form would allocate
330+
// an arguments array per invocation and the exact number of arguments
331+
// passed to the user callback is observable. Consumers of a raw callback
332+
// invoke it inside try/catch and route the result through
333+
// thenAlgorithmResult() below.
340334
function createRawCallback1Param(name, fn, thisArg) {
341335
validateFunction(fn, name);
342336
return (arg) => FunctionPrototypeCall(fn, thisArg, arg);
@@ -347,6 +341,46 @@ function createRawCallback2Params(name, fn, thisArg) {
347341
return (arg1, arg2) => FunctionPrototypeCall(fn, thisArg, arg1, arg2);
348342
}
349343

344+
// Async wrapper of a one-shot callback (the source cancel method).
345+
function createPromiseCallback1Param(name, fn, thisArg) {
346+
validateFunction(fn, name);
347+
return async (arg) => FunctionPrototypeCall(fn, thisArg, arg);
348+
}
349+
350+
// The one-shot methods of a sink or transformer (close, abort, flush,
351+
// cancel) are instead stored as-is in the controller state next to the
352+
// object they belong to and called the way the reference implementation's
353+
// promiseCall() does: a synchronous throw becomes a rejection and the
354+
// result is adopted through a fresh promise, so a returned promise settles
355+
// the caller two microtasks later and a plain value settles it at once.
356+
function promiseCall1Param(fn, thisArg, arg) {
357+
let result;
358+
try {
359+
result = FunctionPrototypeCall(fn, thisArg, arg);
360+
} catch (error) {
361+
return PromiseReject(error);
362+
}
363+
return promiseResolvedWith(result);
364+
}
365+
366+
function promiseCallNoParams(fn, thisArg) {
367+
let result;
368+
try {
369+
result = FunctionPrototypeCall(fn, thisArg);
370+
} catch (error) {
371+
return PromiseReject(error);
372+
}
373+
return promiseResolvedWith(result);
374+
}
375+
376+
function promiseResolvedWith(value) {
377+
if (value === null ||
378+
(typeof value !== 'object' && typeof value !== 'function')) {
379+
return PromiseResolve(value);
380+
}
381+
return new Promise((resolve) => resolve(value));
382+
}
383+
350384
// A single shared, forever-resolved promise used to enqueue a reaction at
351385
// the next microtask checkpoint without allocating a fresh promise.
352386
const kResolvedPromise = PromiseResolve();
@@ -385,16 +419,6 @@ function thenAlgorithmResult(result, onFulfilled, onRejected) {
385419
}
386420
}
387421

388-
function createPromiseCallback1Param(name, fn, thisArg) {
389-
validateFunction(fn, name);
390-
return async (arg) => FunctionPrototypeCall(fn, thisArg, arg);
391-
}
392-
393-
function createPromiseCallback2Params(name, fn, thisArg) {
394-
validateFunction(fn, name);
395-
return async (arg1, arg2) => FunctionPrototypeCall(fn, thisArg, arg1, arg2);
396-
}
397-
398422
function isPromisePending(promise) {
399423
if (promise === undefined) return false;
400424
const details = getPromiseDetails(promise);
@@ -438,12 +462,11 @@ function setPromiseHandled(promise) {
438462
PromisePrototypeThen(promise, undefined, () => {});
439463
}
440464

441-
async function nonOpFlush() {}
442-
443465
// Shared non-op for the start/pull/write algorithm callbacks, which all
444-
// follow the raw-callback contract (see createRawCallback*): the
466+
// follow the raw-callback contract (see createRawCallback*), and for the
467+
// missing one-shot methods of a user-provided sink or transformer: the
445468
// non-thenable return takes the allocation-free fast path in
446-
// thenAlgorithmResult().
469+
// thenAlgorithmResult() and settles promiseCall*() at once.
447470
function nonOpCallback() {}
448471

449472
async function nonOpCancel() {}
@@ -463,9 +486,7 @@ module.exports = {
463486
canCopyArrayBuffer,
464487
cloneAsUint8Array,
465488
copyArrayBuffer,
466-
createPromiseCallbackNoParams,
467489
createPromiseCallback1Param,
468-
createPromiseCallback2Params,
469490
createRawCallback1Param,
470491
createRawCallback2Params,
471492
customInspect,
@@ -489,9 +510,9 @@ module.exports = {
489510
materializeQueue,
490511
nonOpCallback,
491512
nonOpCancel,
492-
nonOpFlush,
493-
494513
peekQueueValue,
514+
promiseCall1Param,
515+
promiseCallNoParams,
495516
rejectedHandledRecord,
496517
resetQueue,
497518
resolvedRecord,

0 commit comments

Comments
 (0)