Skip to content

perf(ext/web): pump readableStreamCollectIntoUint8Array with a persistent read request#35790

Merged
bartlomieju merged 6 commits into
mainfrom
perf/streams-collect-pump
Jul 6, 2026
Merged

perf(ext/web): pump readableStreamCollectIntoUint8Array with a persistent read request#35790
bartlomieju merged 6 commits into
mainfrom
perf/streams-collect-pump

Conversation

@bartlomieju

@bartlomieju bartlomieju commented Jul 5, 2026

Copy link
Copy Markdown
Member

Follow-up to #35786 and #35788. The slow path of
readableStreamCollectIntoUint8Array, which backs every Body consumer for
JS-source streams (.bytes(), .text(), .json(), .arrayBuffer(), Blob),
looped over reader.read() and paid a Deferred, a read request instance,
a {value, done} result object, and an await microtask hop per chunk.

It now pumps with one persistent read request: synchronously available
chunks are delivered re-entrantly and re-armed in a do/while at constant
stack depth with zero microtask hops, and asynchronous chunks re-enter
the pump paying only the source's own pull reaction hop. Error and close
semantics are unchanged, including the TypeError on non-Uint8Array
chunks.

Benchmark (M1 Max, release build):

scenario before after
.bytes(), 1KB chunks 791 ns/chunk 657 ns/chunk
same, equivalent userland read loop 774 ns/chunk 700 ns/chunk

The consumer previously trailed a hand-written reader.read() loop over
the same stream; it now beats it. WPT streams (153 passed, 0 failed, 4
expected failures, identical to baseline), fetch/api/response (54), and
fetch/api/body (4) are green.

Towards #35768

…r reader.read()

Replaces per-read readRequest/readIntoRequest object literals (one object
plus three closure methods per read) with classes holding the Deferred in
ReadableStreamDefaultReader.read() and ReadableStreamBYOBReader.read(view).

Hoists the tee read requests and their chunkSteps microtask callbacks out
of the pull algorithms in readableStreamDefaultTee and readableByteStreamTee
so they are allocated once per tee() instead of once per chunk. Safe because
the reading flag guarantees at most one read is in flight; the pending chunk
travels through a scope slot consumed at the start of the microtask.

Benchmark (M1 Max, release build, 1000 chunks/stream, ns/chunk):

  tee + drain both branches   472 -> 429  (-9%)
  drain, no tee               168 -> 160  (-5%)
  byte tee + drain both      1468 -> 1423 (-3%)

GC share of profiled ticks drops from 7.4% to 3.0% on the tee hot loop.

Towards #35768, #35771
…ller

readableStreamDefaultControllerCallPullIfNeeded and
readableByteStreamControllerCallPullIfNeeded allocated four objects per
pull for the pull-promise reactions (two handlers plus the two
assertion-rethrow wrappers uponPromise creates). Create the pre-wrapped
handler pair lazily on first pull and reuse it for every subsequent pull
on that controller. Same pattern as the sink write reaction handlers on
the writable side.

Benchmark (M1 Max, release build, 1000 chunks/stream, ns/chunk,
on top of the read-request reuse commit):

  tee + drain both branches   429 -> 396
  drain, no tee               160 -> 157
  byte tee + drain both      1423 -> 1367

Cumulative from baseline: tee -16%, drain -7%, byte tee -7%.

Towards #35768
…thms

Internal pull algorithms (both tee variants, the default no-op pull, and
the static fetch body stream) returned PromiseResolve(undefined) on every
pull, which callPullIfNeeded immediately subscribed to, allocating a
fresh resolved promise per pull. They now return undefined as a
synchronous completion sentinel, and both callPullIfNeeded variants
subscribe the per-controller fulfilled handler to a shared lazily
created resolved promise instead.

A reaction subscribed to an already-resolved promise runs at the same
position in the microtask queue regardless of which resolved promise it
is attached to, so observable ordering (number and timing of source
pull() calls, read resolution order) is unchanged. WPT streams is green
(153 passed, 0 failed, 4 expected failures, identical to baseline), as
is fetch/api/response (54 passed).

Dispatching through queueMicrotask instead was measured and rejected: its
dispatch is ~15ns/tick slower than a resolved-promise reaction (plus the
core wrapper closure), a net regression on the tee bench.

User-provided pull callbacks are unaffected: they go through
webidl.invokeCallbackFunction, which always returns a promise.

Benchmark (M1 Max, release build, 1000 chunks/stream, ns/chunk, on top
of the per-controller pull handlers commit):

  tee + drain both branches   396 -> 391
  drain, no tee               157 -> 155

Cumulative from the pre-series baseline: tee 472 -> 391 (-17%),
drain 168 -> 155 (-8%), byte tee 1468 -> 1390 (-5%).

Towards #35768, #35771
When an internal pull algorithm signals synchronous completion (returns
undefined), callPullIfNeeded previously still subscribed the fulfilled
handler to a resolved promise, paying a reaction dispatch and a derived
promise per pull just to flip pulling back to false one microtask later.
Run those upon-fulfillment steps synchronously instead: clear pulling,
and if pullAgain was set during the pull, recurse into callPullIfNeeded,
which re-checks shouldCallPull exactly like the async reaction would.

The observable difference is that a subsequent pull can start earlier
(same synchronous segment instead of after a microtask). WPT streams and
fetch/api/response validate that this is not distinguishable by spec
ordering tests.

Termination of the synchronous re-pull is guaranteed by the internal
algorithms' own re-entry guards (the tee reading flag, closeRequested on
one-shot body sources).

Towards #35768, #35771

Benchmark (M1 Max, release build, 1000 chunks/stream, ns/chunk, on top
of the sentinel commit):

  tee + drain both branches   391 -> 348
  drain, no tee               155 -> 152
  byte tee + drain both      1390 -> 1341

Cumulative from the pre-series baseline: tee 472 -> 348 (-26%),
drain 168 -> 152 (-10%), byte tee 1468 -> 1341 (-9%). WPT streams
153 passed / 0 failed / 4 expected failures (identical to baseline);
fetch/api/response 54/54.
…tent read request

The slow path of readableStreamCollectIntoUint8Array (every Body
consumer for a JS-source stream: .bytes(), .text(), .json(),
.arrayBuffer(), Blob) looped over reader.read(), paying a Deferred, a
read request, a {value, done} result object, and an await microtask hop
per chunk.

Pump with one persistent read request instead: synchronously available
chunks are delivered re-entrantly by readableStreamDefaultReaderRead and
re-armed in a loop (constant stack depth, zero microtask hops), and
asynchronously delivered chunks re-enter the pump from chunkSteps,
paying only the source's own pull reaction hop.

Error and close behavior are unchanged: a non-Uint8Array chunk rejects
with the same TypeError and stops reading, stream errors reject, and
the reader is left in the same state as before.

Towards #35768

Benchmark (M1 Max, release, vs the same binary's machinery): .bytes()
with 1KB chunks 791 -> 657 ns/chunk vs canary and now beats the
equivalent userland read loop (657 vs 700) where it previously trailed
it; streamed 8MB body baseline read ~4.4 -> ~5.1 GB/s across the full
branch stack. WPT streams 153/0/4-expected, fetch/api/response 54/54,
fetch/api/body 4/4.
Base automatically changed from perf/web-streams-sync-pull to main July 6, 2026 11:33
@bartlomieju
bartlomieju merged commit 003c72e into main Jul 6, 2026
137 checks passed
@bartlomieju
bartlomieju deleted the perf/streams-collect-pump branch July 6, 2026 12:26
bartlomieju added a commit that referenced this pull request Jul 7, 2026
Addresses the remaining generic-`pipeTo` work from #35768 (the
read-request reuse, pull-reaction, and identity-bypass items already landed in
#35786, #35788, #35790, #35799).

Towards #35768
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant