Repository navigation
perf(ext/web): add hyper-fast path for TextDecoder.decode - #33674
Conversation
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
|
@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
left a comment
There was a problem hiding this comment.
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 defaultnew TextDecoder()(no args) lands here; explicitnew TextDecoder("utf-8", { fatal: true })does not, and correctly takes the slow path. βthis.#handle === nullβ guarantees no streaming decoder state is open. The previousdecode({ stream: true })call would have set#handle; the slow path'stry { ... } finally { ... }is needed only when#handleis non-null, so skipping it here is sound. βTypedArrayPrototypeGetSymbolToStringTag(input) === "Uint8Array"βUint8ClampedArray(@@toStringTag === "Uint8ClampedArray") andDataViewcorrectly fall through to the slow path'sBufferSource/SAB handling. β!isSharedArrayBuffer(...)β SAB-backed inputs need the slow path'sprototype.sliceclone 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.
|
@bartlomieju pushed 593410e β moves the per-byte win from V8's internal UTF-8 validation pass into a
The 12 kB cases that dominate real workloads ( Behavioural envelope is unchanged: invalid UTF-8 still produces U+FFFD (or throws under Bench harness extended with |
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.
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.
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.
Summary
The dominant case of
new TextDecoder().decode(bytes)(ortextDecoder.decode(bytes)on a long-lived decoder with a regularUint8Array) hits the existing fast path but still pays for redundantwork:
isSharedArrayBufferlookups happen twice (once at theentry guard, once inside the
try { ... }block that extractsbufferonly to feed the SAB-clone branch),try { ... } finally { ... }block guards a#handlethat isalready known to be
null,even when the caller passed no options.
This PR adds an early-return at the top of
decode()that fires when:options === undefined,!fatal),#handleis open,Uint8Arraywhose buffer is not aSharedArrayBuffer.When that holds, the existing slow path also lands at
op_encoding_decode_utf8(input, this.#ignoreBOM), so behaviour isidentical. SAB-backed inputs and non-
Uint8Arrayviews still flowthrough the full slow path (clone-out for SAB,
BufferSourceconversion for
DataView/ non-Uint8Arraytyped arrays, etc.).Numbers
cargo bench -p deno_web --bench encoding, release, mean of 5 runs:bench_decode_120b_reusedbench_decode_120b_freshbench_decode_12kb_reusedbench_encode_12kb(=12kB fresh)ns/iterhere is the bench harness loop time for 1000 inneriterations, 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.rsis extended with three new benches thatcover the reused-decoder and small-input shapes; the pre-existing
bench_encode_12kbis unchanged.Test plan
cargo bench -p deno_web --bench encodingx5 before / aftertests/unit/text_encoding_test.ts(coversSAB-backed
Uint8Array,Int32Array, big5/shift_jis/euc-krdecoders, etc. β these all force the slow path)
encoding/suite