Skip to content

perf(ext/web): synchronous transform fast path for TransformStream - #35844

Merged
bartlomieju merged 1 commit into
mainfrom
perf/text-encoding-stream
Jul 7, 2026
Merged

bartlomieju merged 1 commit into
mainfrom
perf/text-encoding-stream

Conversation

@bartlomieju

Copy link
Copy Markdown
Member

Summary

A JS transformer's transform() was invoked through WebIDL's Promise<undefined>
return converter and its result chained with transformPromiseWith(), so every
chunk through a non-identity TransformStream allocated two promises and took
a microtask hop β€” even when transform() completed synchronously. Only the
identity transform had a fast path.

This invokes transform() directly and returns the synchronous-completion
sentinel (undefined) on any non-thenable result, so
transformStreamDefaultControllerPerformTransform resolves the write with a
shared resolved promise and no microtask hop β€” mirroring the existing identity
fast path and the default controller's synchronous-pull fast path (#35843). A
synchronous throw becomes a rejected promise (the stream errors as before); a
thenable return keeps the full async path (matching
webidl.invokeCallbackFunction's returnsPromise behavior).

TextEncoderStream and TextDecoderStream transforms are made synchronous
(return undefined instead of PromiseResolve()) so they take this path.

Benchmarks

macOS arm64, best-of-3, 100k chunks. Baseline is this branch with the change
reverted (identical build config).

workload baseline this PR Ξ”
generic non-identity transform pipe 2.39M/s 2.54M/s +6%
3Γ— transform chain 1.12M/s 1.24M/s +11%
TextEncoderStream β†’ TextDecoderStream pipe 1.06M/s 1.13M/s +6.6%

For reference (Bun 1.4 canary): generic transform 2.14M/s, 3Γ— chain 1.03M/s,
Text En/Decode 1.21M/s. This change moves generic transform pipes past Bun;
the remaining Text En/Decode gap is core.encode/TextDecoder.decode op cost
(native in Bun), not the stream machinery.

Correctness / notes

  • Same class of timing change as the identity and sync-pull fast paths (write
    resolves synchronously instead of a microtask later). Reuses the existing
    resolvedPromise() used by the identity path.
  • TE/TD correctness verified: encode/decode round-trip, surrogate pair split
    across chunks, lone high surrogate β†’ replacement char, streaming multi-byte
    UTF-8 split across byte chunks, fatal decode error propagation, input
    coercion.
  • tests/unit/text_encoding_test.ts + tests/unit/streams_test.ts: 89/89 pass.
  • Relying on CI for full WPT streams + encoding coverage.

A JS transformer's transform() was invoked through webidl's `Promise<undefined>`
return converter and its result chained with transformPromiseWith(), so every
chunk through a non-identity TransformStream allocated two promises and took a
microtask hop -- even when transform() completed synchronously. Only the
identity transform had a fast path.

Invoke transform() directly and return the synchronous-completion sentinel
(undefined) on any non-thenable result, so
transformStreamDefaultControllerPerformTransform resolves the write with a
shared resolved promise and no microtask hop, mirroring the identity fast path
and the default controller's synchronous pull. A synchronous throw becomes a
rejected promise (the stream errors as before); a thenable return keeps the full
async path.

TextEncoderStream and TextDecoderStream transforms are made synchronous (return
undefined instead of PromiseResolve()) so they take this path.

Generic non-identity transform pipes improve ~6-11% (now faster than Bun);
TextEncoderStream/TextDecoderStream ~6%.
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