Optimize Buffer.toString('hex'/'base64') for large buffers (#31421)
### What does this PR do?
`Buffer.prototype.toString` regressed vs the last Zig-based release for
large buffers on the binary-to-text encodings
(`bench/snippets/buffer-to-string.mjs`: `hex` of a 110 KB buffer ~3.7×
slower, `base64`/`base64url` ~17% slower on a quiet Sapphire Rapids
host). Small-buffer cases were already faster than Zig and stay on their
current paths.
**Cause**
- `hex`: the Rust port of `encode_bytes_to_hex` replaced the Zig
`@Vector(16, u8)` nibble-interlace fast path with a scalar,
bounds-checked table loop (`bytes_to_hex_lower`) — the port even left a
`PERF(port)` note about it. Every byte of a 110 KB buffer goes through
that loop.
- `base64`/`base64url`: the encode itself is `simdutf::binary_to_base64`
in both the Zig and Rust builds, so the kernel was never the problem.
The Zig implementation encoded into a `default_allocator` (mimalloc)
buffer and handed it to an external WTF string; the Rust port switched
to `BunString::create_uninitialized_latin1`, which cycles a ~146 KB
block through WTF's string allocator on every call. mimalloc reuses
blocks of that size much more cheaply than WTF's allocator does.
**Fix**
- New `EncodeHexLowerImpl` Highway kernel in `highway_strings.cpp` (same
`HWY_EXPORT` + `HWY_DYNAMIC_DISPATCH` pattern as the existing kernels):
split each byte into nibbles, map both nibble vectors through the
16-byte hex-digit table with `TableLookupBytes`, and write the
interleaved digits with `StoreInterleaved2`. Exposed as
`bun_highway::encode_hex_lower`;
`bun_core::strings::encode_bytes_to_hex` dispatches to it for inputs ≥
64 bytes and keeps the scalar LUT loop below that (small buffers were
already ahead of Zig). Per-target symbols added to the
`verify-baseline-static` allowlists.
- `Buffer.toString("base64"/"base64url")` (`to_bun_string_comptime`) now
encodes outputs ≥ 32 KB into a mimalloc-backed buffer wrapped in an
external WTF string — the strategy the Zig implementation used — via a
shared `encode_base64_to_bun_string` helper. Smaller outputs keep the
uninitialized-WTF-string path, which is what makes the 16-byte cases
faster than Zig today.
Output bytes are unchanged for every encoding and size (see
verification).
### Benchmarks
Shared/noisy Ice Lake Xeon container, medians of interleaved rounds;
ratios are the signal, not absolute numbers. `Zig` is bun 1.3.14,
`before` is canary at 49c97de6b, `after` is this PR (both release builds
from this tree).
`bench/snippets/buffer-to-string.mjs`:
| case | Zig 1.3.14 | before | after |
|---|---|---|---|
| `Buffer(110000).toString('hex')` | 34.6–37.6 µs | 92.7–93.8 µs |
**24.0–24.8 µs** |
| `Buffer(16).toString('hex')` | 105–109 ns | 117–120 ns | 103–112 ns |
| `Buffer(110000).toString('base64')` | 16.0–17.9 µs | 15.3–16.1 µs |
16.1–16.9 µs |
| `Buffer(110000).toString('base64url')` | 16.9–18.7 µs | 15.0–15.4 µs |
16.1–16.7 µs |
| `Buffer(16).toString('base64')` | ~119 ns | 86–91 ns | 104–120 ns
(unchanged path, container noise) |
| `Buffer(110000).toString('ascii')` | 13.4–14.0 µs | 9.5–11.4 µs |
11.6–11.8 µs (untouched path) |
The 110 KB base64 delta (~1 µs on the reporting host) is below this
container's noise floor, so it reads as a wash above; the
allocation-strategy effect is visible once the output is big enough to
clear the noise, in `bench/snippets/buffer-base64.mjs` (`toString`
direction, two rounds):
| size | Zig 1.3.14 | before | after |
|---|---|---|---|
| 64 KB | 10.2 µs | 10.5–15.6 µs | 10.3–10.5 µs |
| 512 KB | 86.0 µs | 96.5–107.0 µs | **89.8–90.7 µs** |
| 8 MB | 1.97 ms | 2.18–2.24 ms | **1.97–2.04 ms** |
Hex is ~3.8× faster than before this change and ~30–40% faster than the
Zig build at 110 KB; large base64 recovers the gap to Zig (and beats it
at 8 MB).
### How did you verify your code works?
- `bun bd test test/js/node/buffer.test.js` — 468 pass, including the
new `Buffer.prototype.toString binary-to-text encodings` block:
byte-for-byte comparison against pure-JS reference encoders across every
length 0–130 plus 192/256/512/1024 ±1 (vector-width boundaries and the
scalar tail), unaligned `subarray` views, every `length % 3` padding
shape, and a 110 000-byte buffer checked against SHA-256 digests that
were cross-verified against bun 1.3.14 (pre-rewrite Zig) and the JS
reference.
- Vendored Node suites that exercise these paths:
`test-buffer-tostring.js`, `test-buffer-alloc.js`,
`test-stringbytes-external.js` (~500 KB hex/base64 encode+decode
round-trips), and `test/js/node/string_decoder/string-decoder.test.js` —
all pass with the debug (ASAN) build.
- `fs.readFile(path, "hex" | "base64" | "base64url")` spot-checked
against `Buffer.toString` output (the readFile hex path shares
`encode_bytes_to_hex`).
- `cargo clippy -p bun_core -p bun_highway -p bun_runtime` clean.
- Baseline-ISA allowlist entries were generated by running
`scripts/verify-baseline-static` against the locally built `bun-profile`
and copying the feature sets it reported per target. The local toolchain
doesn't compile the `AVX10_2` target, so that entry mirrors the SPR/DL
ceilings (+`AVX512VL`); if the baseline/aarch64 CI lanes report
different sets I'll update the entries to match, same as the
`CopyAsciiPrefixImpl` addition did.
- A throughput regression guard in the same describe block asserts that
`toString('hex')` of a 110 KB buffer stays within 6× the cost of a
`toString('latin1')` copy of the same buffer (median of GC-isolated
samples). The scalar per-byte loop sits at ≥10× that baseline while the
SIMD kernel stays at ~2–3×, so this test fails on the pre-SIMD
implementation and passes with this PR; it is skipped on debug/ASAN
builds, where the unoptimized, instrumented native kernels make timing
ratios meaningless. The full before/after picture is the benchmark
tables above, reproducible via `bench/snippets/buffer-to-string.mjs` and
`bench/snippets/buffer-base64.mjs`.
---------
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> R
robobun committed
cab4feba8e841c53fc8c83a1b75c674b250907d7
Parent: 4324120
Committed by GitHub <noreply@github.com>
on 5/26/2026, 10:14:21 AM