perf(ext/web): pump readableStreamCollectIntoUint8Array with a persistent read request#35790
Merged
Conversation
…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.
This was referenced Jul 6, 2026
bartlomieju
added a commit
that referenced
this pull request
Jul 7, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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):
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