SIGN IN SIGN UP
oven-sh / bun UNCLAIMED

Incredibly fast JavaScript runtime, bundler, test runner, and package manager – all in one

0 0 150 Rust

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