Skip to content

perf(ext/node): write UTF-16LE buffers with V8 - #36804

Open
joepjoosten wants to merge 1 commit into
denoland:mainfrom
joepjoosten:perf/buffer-utf16le-write
Open

perf(ext/node): write UTF-16LE buffers with V8#36804
joepjoosten wants to merge 1 commit into
denoland:mainfrom
joepjoosten:perf/buffer-utf16le-write

Conversation

@joepjoosten

Copy link
Copy Markdown

Fixes #36803.

What and why

Buffer.from(text, "utf16le") currently converts the string through a JS per-code-unit loop into a temporary Uint8Array, then copies that into the Buffer. This is slower than a straightforward DataView loop on medium/large inputs.

This change adds a native op using V8's UTF-16 string write API and routes the normal ucs2Write path through it:

  • Aligned little-endian destinations are written directly, avoiding the temporary allocation and copy.
  • Unaligned destinations use a bounded 2 KiB stack buffer. The same fallback emits little-endian bytes on big-endian targets.
  • Raw UTF-16 code units are preserved, including lone surrogates and a surrogate pair split by a byte limit. There is no lossy UTF-8 intermediate.
  • Writes stop at complete code units and respect the destination view and requested length. This also fixes writing a partial code unit with an odd byte limit: Buffer.alloc(3).write("A", 0, 1, "utf16le") now returns 0, matching Node.
  • Non-standard direct-call arguments retain the existing JS fallback. The helper remains available for its other callers.

Related: #35182 also ports internal binding helpers to Rust. This PR is narrower: direct destination writes for Buffer UTF-16LE encoding, with focused compatibility tests and benchmarks. It is based on current main, not that branch.

Benchmarks

Apple M2 Max, macOS ARM64, Deno 2.9.6 / V8 15.0.245.2-rusty. Baseline: 336da420f4343cbb1dcbd5eed9d075ff555ed6ee. Both baseline and candidate were built locally with Rust 1.95.0 using the same release-lite profile, CARGO_INCREMENTAL=0, and CARGO_PROFILE_RELEASE_LITE_DEBUG=0.

Ran the committed tests/bench/buffer_utf16le.js unchanged on both binaries, three times each, sequentially in alternating order (baseline/candidate, candidate/baseline, baseline/candidate), after builds/lint finished. Each value below is the median of three Deno.bench run averages, not a latency percentile. Sizes are UTF-16LE output bytes.

Corpus Output size Before (µs) After (µs) Speedup
ascii 64 bytes 0.489 0.104 4.7×
ascii 4096 bytes 15.227 0.610 25.0×
ascii 65536 bytes 233.401 2.401 97.2×
latin1 64 bytes 0.490 0.103 4.7×
latin1 4096 bytes 15.344 0.601 25.5×
latin1 65536 bytes 232.680 2.411 96.5×
utf16 64 bytes 0.487 0.102 4.8×
utf16 4096 bytes 15.333 0.620 24.7×
utf16 65536 bytes 232.900 2.584 90.1×

For the 64 KiB mixed UTF-16 case:

  • The DataView control stayed approximately flat: 78.684 → 79.207 µs.
  • Aligned Buffer.write into a reused destination: 232.054 → 1.440 µs (~161×).
  • Unaligned Buffer.write: 232.153 → 72.349 µs (~3.2×).
  • The three Buffer.from run averages were 232.900 / 231.448 / 233.129 µs before and 2.554 / 2.584 / 2.657 µs after.

These are warm, encoding-only microbenchmarks, not application-wide speedups. Full output equality is checked before timing. Timed Buffer.from cases allocate output and consume a byte; write-only cases reuse a destination and consume the returned byte count. Performance for other string representations, architectures, and sizes may differ.

Reproduce on baseline and candidate binaries:

deno bench --no-check --json tests/bench/buffer_utf16le.js

Validation

  • cargo build --locked --profile release-lite -p deno --bin deno
  • cargo build --locked --profile release-lite -p test_server --bin test_server
  • cargo test --locked --profile release-lite -p unit_node_tests --test unit_node -- buffer_test
  • cargo test --locked --profile release-lite -p node_compat_tests --test node_compat -- test-buffer-write.js against the candidate binary
  • tools/format.js and tools/format.js --check
  • tools/lint.js (full Rust + JS) and a final tools/lint.js --js rerun
  • git diff --check

New tests cover every UTF-16 code unit across all four encoding aliases, empty strings, NULs, lone/split surrogates, odd lengths/offsets, sliced destination boundaries, large unaligned writes spanning scratch-buffer iterations, and shared backing stores. The new test cases also pass against Node. Big-endian hardware was not available locally.

AI assistance: investigation, implementation, tests, and benchmark analysis were completed with OpenAI Codex.

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.

node:buffer UTF-16LE encoding is slower than a JavaScript DataView loop

1 participant