Skip to content

perf(ext/web): add hyper-fast path for TextDecoder.decode - #33674

Merged
bartlomieju merged 4 commits into
denoland:mainfrom
crowlbot:perf/text-decoder-hyper-fast-path
May 2, 2026
Merged

bartlomieju merged 4 commits into
denoland:mainfrom
crowlbot:perf/text-decoder-hyper-fast-path

Conversation

@crowlbot

Copy link
Copy Markdown
Contributor

Summary

The dominant case of new TextDecoder().decode(bytes) (or
textDecoder.decode(bytes) on a long-lived decoder with a regular
Uint8Array) hits the existing fast path but still pays for redundant
work:

  • the buffer / isSharedArrayBuffer lookups happen twice (once at the
    entry guard, once inside the try { ... } block that extracts
    buffer only to feed the SAB-clone branch),
  • the try { ... } finally { ... } block guards a #handle that is
    already known to be null,
  • option defaulting and the stream branch all run before the op call
    even when the caller passed no options.

This PR adds an early-return at the top of decode() that fires when:

  • options === undefined,
  • the decoder is utf-8 single-pass (utf-8 + !fatal),
  • no streaming #handle is open,
  • input is a regular Uint8Array whose buffer is not a SharedArrayBuffer.

When that holds, the existing slow path also lands at
op_encoding_decode_utf8(input, this.#ignoreBOM), so behaviour is
identical. SAB-backed inputs and non-Uint8Array views still flow
through the full slow path (clone-out for SAB, BufferSource
conversion for DataView / non-Uint8Array typed arrays, etc.).

Numbers

cargo bench -p deno_web --bench encoding, release, mean of 5 runs:

bench before after Ξ”
bench_decode_120b_reused 315 ns/iter 286 ns/iter βˆ’9.2%
bench_decode_120b_fresh 574 ns/iter 554 ns/iter βˆ’3.5%
bench_decode_12kb_reused 1580 ns/iter 1586 ns/iter noise
bench_encode_12kb (=12kB fresh) 1951 ns/iter 1936 ns/iter noise

ns/iter here is the bench harness loop time for 1000 inner
iterations, so the per-call deltas are ~29 ns (small reused) and
~20 ns (small fresh). The 12kB cases are dominated by the underlying
op cost, so the JS-side savings wash out into noise. The win shows up
in the hot small-input cases that streaming / HTTP / JSON parsers hit
repeatedly on a long-lived decoder.

ext/web/benches/encoding.rs is extended with three new benches that
cover the reused-decoder and small-input shapes; the pre-existing
bench_encode_12kb is unchanged.

Test plan

  • cargo bench -p deno_web --bench encoding x5 before / after
  • CI: existing tests/unit/text_encoding_test.ts (covers
    SAB-backed Uint8Array, Int32Array, big5/shift_jis/euc-kr
    decoders, etc. β€” these all force the slow path)
  • CI: WPT encoding/ suite

The dominant case of `new TextDecoder().decode(bytes)` (or
`textDecoder.decode(bytes)` on a long-lived decoder with a regular
Uint8Array) hits the existing fast path but still pays for redundant
work: the buffer/SAB lookups happen twice (once at the entry guard,
once inside the try block), the `try { ... } finally { ... }` block
guards a `#handle` that is already known to be null, and the option
defaulting / stream branch dispatches all run before reaching the
op call.

Add an early-return at the top of decode() that fires when:

  - options === undefined
  - decoder is utf-8 single-pass (utf-8 + !fatal)
  - no streaming handle is open
  - input is a regular Uint8Array (non-SAB)

When that holds, the slow path also lands at
`op_encoding_decode_utf8(input, this.#ignoreBOM)`, so behaviour is
unchanged. SAB-backed inputs and non-Uint8Array views still go through
the full slow path (clone-out for SAB, BufferSource conversion for
DataView/typed arrays, etc.).

ext/web/benches/encoding.rs is extended with three new benches that
exercise reused-decoder and small-input shapes; the existing
`bench_encode_12kb` is unchanged.

Numbers (cargo bench -p deno_web --bench encoding, release, mean of 5):

  bench_decode_120b_reused   315 β†’ 286 ns/iter   -9.2%
  bench_decode_120b_fresh    574 β†’ 554 ns/iter   -3.5%
  bench_decode_12kb_reused  1580 β†’ 1586 ns/iter  noise
  bench_encode_12kb (=12kb fresh) 1951 β†’ 1936    noise

The 12kB cases are dominated by the underlying op_encoding_decode_utf8
work, so the JS-side savings disappear into noise there. The win is in
the hot small-input cases that streaming/HTTP/JSON parsers hit
repeatedly on a long-lived decoder.
# Conflicts:
#	ext/web/benches/encoding.rs
@bartlomieju

Copy link
Copy Markdown
Member

@crowlbot this is nice but I was hoping for a bigger speed up. Can you give it another pass and look for more optimizations?

@fibibot fibibot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Same pattern as #33675 (TextEncoder.encodeInto fast path) β€” early-return when the dominant case applies.

Fast-path correctness:

  • options === undefined β€” bails to the existing options-destructuring path otherwise. βœ“
  • this.#utf8SinglePass β€” true iff encoding is utf-8 AND !fatal (set at construction). The default new TextDecoder() (no args) lands here; explicit new TextDecoder("utf-8", { fatal: true }) does not, and correctly takes the slow path. βœ“
  • this.#handle === null β€” guarantees no streaming decoder state is open. The previous decode({ stream: true }) call would have set #handle; the slow path's try { ... } finally { ... } is needed only when #handle is non-null, so skipping it here is sound. βœ“
  • TypedArrayPrototypeGetSymbolToStringTag(input) === "Uint8Array" β€” Uint8ClampedArray (@@toStringTag === "Uint8ClampedArray") and DataView correctly fall through to the slow path's BufferSource/SAB handling. βœ“
  • !isSharedArrayBuffer(...) β€” SAB-backed inputs need the slow path's prototype.slice clone before the op call; this guard correctly excludes them. βœ“

The fast-path action op_encoding_decode_utf8(input, this.#ignoreBOM) is exactly what the existing slow path resolves to once it has confirmed all the same conditions hold (see ext/web/08_text_encoding.js:118-119 which reuses the inverse of these guards), so behavior is observably identical for inputs taking either path.

Primordials (TypedArrayPrototypeGetSymbolToStringTag, TypedArrayPrototypeGetBuffer, isSharedArrayBuffer) are already imported at the top of the file (lines 15, 38, 41) and used in the existing logic β€” no new imports.

The new benches are appropriate:

  • bench_decode_120b_fresh β€” new TextDecoder().decode(hello120) exercises the fresh-decoder small-input case (HTTP header parsing shape).
  • bench_decode_120b_reused β€” dec.decode(hello120) exercises the long-lived-decoder small-input case (streaming JSON / line-based parsers).
  • bench_decode_12kb_reused β€” confirms the 12kB case is dominated by op cost, so the JS-side savings are noise (matches the body's claim).

The 9.2% / 3.5% wins on the small-input cases come out to ~29 ns / ~20 ns per call, which is the right magnitude for skipping the redundant TypedArrayPrototypeGetBuffer lookup + the try/finally setup.

CI: 131 SUCCESS, 0 FAILURE, 1 pending. Substantively green; the WPT encoding/ and tests/unit/text_encoding_test.ts shards (which exercise SAB, Int32Array, and non-utf-8 decoders that all force the slow path) are among the SUCCESS set.

For pure-ASCII inputs (the dominant real-world case for HTTP/JSON
bodies, file reads, etc.), short-circuit straight to
v8::String::new_from_one_byte and skip both the BOM check and V8's
internal UTF-8 validation pass. simdutf::validate_ascii is a SIMD
high-bit scan (~1ns per 64 bytes); on non-ASCII inputs it bails at
the first non-ASCII byte and falls through to the existing UTF-8
path.

cargo bench -p deno_web --bench encoding, mean of 5 runs (release):

| bench                       |  before |   after |       delta |
|-----------------------------|--------:|--------:|------------:|
| bench_decode_12kb_reused    |   1,490 |     869 |      -41.7% |
| bench_encode_12kb (fresh)   |   1,809 |   1,205 |      -33.4% |
| bench_decode_120b_reused    |     282 |     275 |       noise |
| bench_decode_120b_fresh     |     556 |     550 |       noise |
| bench_decode_utf8_2k_reused |   2,532 |   2,326 |       -8.1% |

The 12kB cases dominate real-world workloads (Response.text(),
JSON.parse(decoder.decode(buf)), file reads) and get the full
per-byte win. The 120B cases are dominated by JS-side overhead so
the per-byte savings wash into noise. Adds bench_decode_utf8_2k_reused
to keep an eye on the non-ASCII path.
@crowlbot

Copy link
Copy Markdown
Contributor Author

@bartlomieju pushed 593410e β€” moves the per-byte win from V8's internal UTF-8 validation pass into a simdutf::validate_ascii + v8::String::new_from_one_byte pair on the Rust side. For pure-ASCII inputs (the dominant real-world shape β€” HTTP/JSON bodies, file reads, serialized data), this skips both the BOM check and V8's UTF-8 decoder.

cargo bench -p deno_web --bench encoding, mean of 5 runs, release build:

bench before after Ξ”
bench_decode_12kb_reused 1,490 869 βˆ’41.7%
bench_encode_12kb (fresh) 1,809 1,205 βˆ’33.4%
bench_decode_utf8_2k_reused 2,532 2,326 βˆ’8.1%
bench_decode_120b_reused 282 275 noise
bench_decode_120b_fresh 556 550 noise

The 12 kB cases that dominate real workloads (Response.text(), JSON.parse(decoder.decode(buf)), file reads) get the full per-byte win. Small inputs are dominated by JS-side overhead and stay flat. Non-ASCII inputs bail out of validate_ascii at the first high-bit byte and fall through to the existing new_from_utf8 path β€” slightly faster too, probably because the SIMD scan warms cache lines.

Behavioural envelope is unchanged: invalid UTF-8 still produces U+FFFD (or throws under fatal: true), BOM stripping still happens for non-ASCII inputs, SAB/DataView/non-Uint8Array views still go through the slow path, and the ignoreBOM=true case is handled correctly (BOM bytes have the high bit set so they fail the ASCII check and reach the existing path that preserves the BOM).

Bench harness extended with bench_decode_utf8_2k_reused (mostly-emoji content) so the non-ASCII branch keeps benchmark coverage going forward.

CI clippy::needless_borrow flagged `let buf: &[u8] = &zero_copy;` on
the previous commit (the explicit type annotation made the redundant
borrow visible). Drop the binding and use `zero_copy` directly.
@bartlomieju
bartlomieju merged commit f96c625 into denoland:main May 2, 2026
268 of 270 checks passed
bartlomieju pushed a commit that referenced this pull request May 5, 2026
For pure-ASCII inputs (the dominant real-world case for
`Response.text()`, `File.text()`, FormData parsing, `prompt()` input,
etc.), short-circuit straight to `v8::String::new_from_one_byte` and
skip both the BOM check and V8's internal UTF-8 validation pass.
`v8::simdutf::validate_ascii` is a SIMD high-bit scan (~1 ns per 64
bytes); on non-ASCII inputs it bails at the first non-ASCII byte and
falls through to the existing UTF-8 path.

Same pattern as #33674 (which optimizes `ext/web`'s
`op_encoding_decode_utf8`); this PR optimizes the parallel
implementation in `deno_core`'s `op_decode`, which is the one used by
all the body-decode call sites listed above.
littledivy pushed a commit to crowlKats/deno that referenced this pull request Jun 10, 2026
For pure-ASCII inputs (the dominant real-world case for
`Response.text()`, `File.text()`, FormData parsing, `prompt()` input,
etc.), short-circuit straight to `v8::String::new_from_one_byte` and
skip both the BOM check and V8's internal UTF-8 validation pass.
`v8::simdutf::validate_ascii` is a SIMD high-bit scan (~1 ns per 64
bytes); on non-ASCII inputs it bails at the first non-ASCII byte and
falls through to the existing UTF-8 path.

Same pattern as denoland#33674 (which optimizes `ext/web`'s
`op_encoding_decode_utf8`); this PR optimizes the parallel
implementation in `deno_core`'s `op_decode`, which is the one used by
all the body-decode call sites listed above.
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.

3 participants