Optimize TextEncoder.encode: restore SIMD ASCII fast paths lost in the Rust port (#31385)
### What does this PR do? `TextEncoder.encode`'s Rust port was measurably slower than the Zig implementation it replaced (4-char ASCII ~2× slower, 12 KB ASCII ~40% slower; only the large UTF-8 case was a tie). This PR restores the lost fast paths and vectorization: **Cause** - `TextEncoder__encode8/16/encodeRopeString` zero-filled a 2 KB stack buffer on every call (`[0u8; 2048]`) where the Zig code used `undefined`, then copied the output a second time from that buffer into JSC memory. For a 4-byte input the memset alone is most of the regression. - The 12 KB ASCII benchmark case is actually JSC **rope** iteration (`"Hello World!".repeat(1024)` stays a rope): ~1024 12-byte leaves hit `copy_latin1_into_utf8_stop_on_non_ascii`, whose port replaced the Zig fused `@Vector(16,u8)`/SWAR copy with a scan (`first_non_ascii`) followed by a `memcpy` per span — two passes plus per-leaf call overhead. - `element_length_latin1_into_utf8` was ported as a scalar span loop instead of `simdutf.length.utf8.from.latin1`. - The small UTF-16 path paid an exact `utf8_length_from_utf16le` pass even when the destination buffer already provably fit the worst case (the Zig `out_len` shortcut was dropped). **Fix** - New `CopyAsciiPrefix` highway kernel in `highway_strings.cpp` (same `HWY_EXPORT` + `HWY_DYNAMIC_DISPATCH` pattern as the existing kernels): fused scan+copy of the leading ASCII run that stops at the first byte ≥ 0x80 and writes only the bytes it reports. Exposed as `bun_highway::copy_ascii_prefix`, with per-target symbols added to the `verify-baseline-static` allowlists. - `copy_latin1_into_utf8_stop_on_non_ascii` now streams through a fused `copy_ascii_prefix` helper: SWAR `u64` for short runs (rope leaves are usually a dozen bytes, so FFI dispatch isn't worth it), the highway kernel for runs ≥ 64 bytes. Only `buf[..written]` is ever written, same as before. - `element_length_latin1_into_utf8` uses simdutf (scalar count for ≤ 32 bytes), matching the Zig original. - `copy_utf16_into_utf8` skips the exact-length pass when `buf.len() >= 3 * utf16.len()` (worst case), mirroring the Zig `out_len` selection; a `copy_utf16_into_utf8_with_utf8_len` variant lets callers that already computed the length (to size the destination) avoid recomputing it. - `TextEncoder__encode8` / `__encode16` now encode straight into an exactly-sized, JSC-owned `Uint8Array` — no stack buffer, no second copy, and the former large-string path no longer hands JSC an external `Vec`. `encode16` keeps a small 192-byte stack path for tiny strings and falls back to the allocating U+FFFD path when unpaired surrogates make the exact-size buffer insufficient. `TextEncoder__encodeRopeString` allocates the array up front and lets the rope iterator write each segment directly into it. Output bytes, `encodeInto` read/written semantics, and the "never write past `written`" guarantee are unchanged. ### Benchmarks `bench/snippets/text-encoder.mjs`, median of 3 interleaved rounds on linux x64 (AVX2/AVX-512), comparing the last Zig-based release (1.3.9), the current Rust implementation, and this PR (both built with the `release` profile from this tree): | case | Zig 1.3.9 | Rust before | Rust after | |---|---|---|---| | 4 ascii | 40.6 ns | 61.8 ns | **40.9 ns** | | 4 utf8 | 59.4 ns | 80.2 ns | **59.6 ns** | | 12 ascii | 45.2 ns | 66.0 ns | **47.3 ns** | | 12 utf8 | 71.0 ns | 114.1 ns | **71.2 ns** | | 12288 ascii (rope) | 16.95 µs | 21.5 µs | **16.58 µs** | | 18432 utf8 | 10.54 µs | 8.60 µs | **8.37 µs** | The tiny-string cases are now bounded by shared machinery (call dispatch, `Uint8Array` allocation), so they land at parity with Zig instead of 1.5–2× behind; the large cases win outright. Additional paths not in the stock benchmark, same methodology: | case | Zig 1.3.9 | Rust before | Rust after | |---|---|---|---| | encode 12288 ascii, resolved (non-rope) string | 1.98 µs | 1.86 µs | **1.51 µs** | | encode 12289 latin1 (1 non-ASCII byte) | 2.69 µs | 2.44 µs | **2.04 µs** | | encodeInto 12288 ascii | 838 ns | 330 ns | **201 ns** | | encode 1 MB ascii | 165 µs | 166 µs | **161 µs** | (The benchmark host is a shared/noisy container; medians of interleaved runs are reported. Ratios, not absolute numbers, are the signal.) ### How did you verify your code works? - `bun bd test test/js/web/encoding/` — all TextEncoder/TextEncoderStream/TextDecoder tests pass with the debug (ASAN) build. The only failures in that directory are the two pre-existing `TextDecoder ... should not leak the output buffer` RSS tests, which fail on any local ASAN debug build because the binary isn't named `bun-asan` (the measured delta is within the test's own ASAN allowance). - Added boundary coverage to `test/js/web/encoding/text-encoder.test.js` (10 new tests, ~10k assertions): every length around the SWAR/SIMD thresholds, a non-ASCII byte at every word/vector boundary position, `encodeInto` exact-fit and partial-fit behavior including "bytes past `written` are untouched", rope strings with >64-byte segments and with non-ASCII segments (bail path), and long UTF-16 strings with and without unpaired surrogates, all cross-checked against a pure-JS reference encoder. - `cargo clippy -p bun_core -p bun_highway` clean; websocket/inspect suites (which share the rewritten Latin-1 kernels) pass apart from tests that dial external network endpoints unavailable in the sandbox. - The baseline-ISA allowlist entries for the new kernel follow the existing `FirstNonAscii8Impl`/`FillWithSkipMaskImpl` ceilings; if `verify-baseline-static` reports different feature sets on the baseline/aarch64 CI builds I'll update the entries to match its report. - Note on test coverage: this is a deliberately behavior-preserving optimization, so the new tests are regression coverage for the rewritten paths (they pass before and after this change by design — there is no functional delta to assert). The before/after evidence for the optimization itself is the benchmark tables above, reproducible via `bench/snippets/text-encoder.mjs`. --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
R
robobun committed
0b1e7e507bc1f76d248099f4668ef5ee099b8aeb
Parent: 146afa3
Committed by GitHub <noreply@github.com>
on 5/26/2026, 1:37:34 AM