Skip to content

perf(ext/web): optimize node:buffer base64 encode/decode#32647

Merged
bartlomieju merged 7 commits intodenoland:mainfrom
bartlomieju:perf/base64-buffer-encoding
Mar 14, 2026
Merged

perf(ext/web): optimize node:buffer base64 encode/decode#32647
bartlomieju merged 7 commits intodenoland:mainfrom
bartlomieju:perf/base64-buffer-encoding

Conversation

@bartlomieju
Copy link
Copy Markdown
Member

@bartlomieju bartlomieju commented Mar 11, 2026

Summary

Optimizes node:buffer base64 encode/decode/write to close the performance gap with Node.js.

Decode & Write

  • Add dedicated op_base64_decode_into fast-call op that decodes base64 directly into a target buffer, used by Buffer.prototype.write(str, 'base64') — avoids allocating intermediate Uint8Array
  • Two-tier decode strategy: base64_simd::STANDARD.decode fast path for properly-padded base64 (zero intermediate copies), with fallback to forgiving_decode for whitespace/missing padding
  • Switch op_base64_decode to use forgiving_decode_to_vec for cleaner single-pass decoding

Encode

  • Add op_base64_encode_from_buffer op that encodes a sub-range of a buffer to base64 using v8::String::new_from_one_byte directly (base64 is always ASCII, avoids UTF-8 processing overhead)
  • Stack allocation for inputs up to 6KB (≤8KB base64 output) to avoid heap allocation
  • Hybrid approach: small buffers (≤4KB) use lightweight #[string] return path, large buffers use new_from_one_byte for better throughput

JS dispatch

  • Add base64 fast paths in Buffer.prototype.toString and Buffer.prototype.write to skip getEncodingOps dispatch overhead (avoids toLowerCase() on every call)

Benchmark results (50K iterations, vs Node.js)

Size Encode Decode Write
32B 1.38x 0.78x 1.77x
128B 1.15x 1.53x 2.67x
512B 0.85x 1.38x 1.67x
4KB 0.63x 1.02x 0.94x
64KB 0.74x 0.96x 0.85x
1MB 1.11x 0.88x 0.85x

Faster than Node on 7/18 benchmarks, within 0.85-0.96x on most of the rest.

Towards #24323

Test plan

  • All correctness tests pass (roundtrip encode/decode for sizes 0-65536, unpadded base64, whitespace in base64, non-base64 characters, atob/btoa)
  • tools/format.js and tools/lint.js --js pass

🤖 Generated with Claude Code

bartlomieju and others added 5 commits March 11, 2026 18:51
Three optimizations for base64 operations in node:buffer:

1. Add `op_base64_decode_into` - decodes base64 directly into a target
   buffer at an offset, eliminating the intermediate Uint8Array allocation
   and blitBuffer copy that `base64Write` previously required.
   Uses stack allocation for inputs ≤8KB to avoid heap alloc overhead.

2. Add `op_base64_encode_from_buffer` - encodes a sub-range of a buffer
   to base64, avoiding the JS-side TypedArrayPrototypeSlice copy in
   `base64Slice`.

3. Change `op_base64_decode` to use `#[string(onebyte)] Cow<[u8]>` instead
   of `#[string] String`, avoiding UTF-8 conversion overhead since base64
   is always ASCII.

Benchmarks (50K iterations, 1K warmup, 4KB payload):
- decode: 1849 → 2483 MB/s (+34%)
- write:  2332 → 3547 MB/s (+52%)
- encode: unchanged (already competitive)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
For properly-padded base64 (the common case), decode directly from input
to target buffer with zero intermediate copies. Falls back to forgiving
decode for whitespace/missing padding. Also switch op_base64_decode to
forgiving_decode_to_vec for cleaner single-pass decoding.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…t paths

- op_base64_encode_from_buffer now uses v8::String::new_from_one_byte
  directly (base64 is always ASCII), with stack allocation for ≤6KB input
- Add base64 fast paths in Buffer.prototype.toString and write to skip
  getEncodingOps dispatch overhead (avoids toLowerCase on every call)
- Hybrid approach: small buffers (<=4KB) use #[string] return path,
  large buffers use new_from_one_byte for better throughput

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
bartlomieju and others added 2 commits March 11, 2026 23:53
base64_simd::STANDARD.decode panics (assert!) when the destination
buffer is smaller than the decoded length, rather than returning Err.
This caused crashes when Buffer.write() was called with a long base64
string on a small buffer. Also fixes clippy lint for manual div_ceil.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Member

@nathanwhit nathanwhit left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@bartlomieju bartlomieju merged commit 081e2e8 into denoland:main Mar 14, 2026
113 checks passed
@bartlomieju bartlomieju deleted the perf/base64-buffer-encoding branch March 14, 2026 07:14
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.

2 participants