Repository: denoland/deno
Pull request: perf(ext/web): optimize node:buffer base64 encode/decode

## 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 https://github.com/denoland/deno/issues/24323

## Test plan
- [x] All correctness tests pass (roundtrip encode/decode for sizes 0-65536, unpadded base64, whitespace in base64, non-base64 characters, atob/btoa)
- [x] `tools/format.js` and `tools/lint.js --js` pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Changed files:
filename: ext/node/polyfills/internal/buffer.mjs
patch: @@ -110,7 +110,9 @@ import {
 } from "ext:deno_node/internal/errors.ts";
 import { getOptionValue } from "ext:deno_node/internal/options.ts";
 import {
+  forgivingBase64DecodeInto,
   forgivingBase64Encode,
+  forgivingBase64EncodeFromBuffer,
   forgivingBase64UrlEncode,
 } from "ext:deno_web/00_infra.js";
 import { atob, btoa } from "ext:deno_web/05_base64.js";
@@ -459,6 +461,7 @@ function fromString(string, encoding) {
   if (!BufferIsEncoding(encoding)) {
     throw new codes.ERR_UNKNOWN_ENCODING(encoding);
   }
+
   const maxLength = Buffer.poolSize >>> 1;
   const length = byteLength(string, encoding) | 0;
   if (length >= maxLength) {
@@ -736,6 +739,11 @@ Buffer.prototype.toString = function toString(encoding, start, end) {
     );
   }
 
+  // Fast path for base64 - skip getEncodingOps dispatch overhead
+  if (encoding === "base64") {
+    return this.base64Slice(start, end);
+  }
+
   const ops = getEncodingOps(encoding);
   if (ops === undefined) {
     throw new codes.ERR_UNKNOWN_ENCODING(encoding);
@@ -964,21 +972,28 @@ Buffer.prototype.base64Slice = function base64Slice(
   offset,
   length,
 ) {
-  if (offset === 0 && length === this.length) {
+  // Use forgivingBase64Encode (#[string] return) for small buffers where
+  // the lighter-weight op2 string path is faster.
+  // Use forgivingBase64EncodeFromBuffer (v8::String::new_from_one_byte) for
+  // large buffers where avoiding UTF-8 processing matters.
+  if (offset === 0 && length === this.length && length <= 4096) {
     return forgivingBase64Encode(this);
-  } else {
-    return forgivingBase64Encode(
-      TypedArrayPrototypeSlice(this, offset, length),
-    );
   }
+  return forgivingBase64EncodeFromBuffer(this, offset, length - offset);
 };
 
 Buffer.prototype.base64Write = function base64Write(
   string,
   offset,
   length,
 ) {
-  return blitBuffer(base64ToBytes(string), this, offset, length);
+  try {
+    const written = forgivingBase64DecodeInto(string, this, offset);
+    return length !== undefined ? MathMin(written, length) : written;
+  } catch {
+    // Fallback for strings with base64url chars or invalid chars
+    return blitBuffer(base64ToBytes(string), this, offset, length);
+  }
 };
 
 Buffer.prototype.base64urlSlice = function base64urlSlice(
@@ -1118,6 +1133,11 @@ Buffer.prototype.write = function write(string, offset, length, encoding) {
     return this.utf8Write(string, offset, length);
   }
 
+  // Fast path for base64 - skip getEncodingOps dispatch overhead
+  if (encoding === "base64") {
+    return this.base64Write(string, offset, length);
+  }
+
   const ops = getEncodingOps(encoding);
   if (ops === undefined) {
     throw new codes.ERR_UNKNOWN_ENCODING(encoding);
@@ -1165,12 +1185,10 @@ function fromArrayBuffer(obj, byteOffset, length) {
 }
 
 function _base64Slice(buf, start, end) {
-  if (start === 0 && end === buf.length) {
+  if (start === 0 && end === buf.length && end <= 4096) {
     return forgivingBase64Encode(buf);
-  } else {
-    // deno-lint-ignore prefer-primordials
-    return forgivingBase64Encode(buf.slice(start, end));
   }
+  return forgivingBase64EncodeFromBuffer(buf, start, end - start);
 }
 const decoder = new TextDecoder("utf-8", { ignoreBOM: true });
 

filename: ext/web/00_infra.js
patch: @@ -7,7 +7,12 @@
 /// <reference path="../../cli/tsc/dts/lib.deno_web.d.ts" />
 
 import { core, internals, primordials } from "ext:core/mod.js";
-import { op_base64_decode, op_base64_encode } from "ext:core/ops";
+import {
+  op_base64_decode,
+  op_base64_decode_into,
+  op_base64_encode,
+  op_base64_encode_from_buffer,
+} from "ext:core/ops";
 const {
   ArrayPrototypeJoin,
   ArrayPrototypeMap,
@@ -250,6 +255,16 @@ function forgivingBase64Encode(data) {
   return op_base64_encode(data);
 }
 
+/**
+ * @param {Uint8Array} data
+ * @param {number} offset
+ * @param {number} length
+ * @returns {string}
+ */
+function forgivingBase64EncodeFromBuffer(data, offset, length) {
+  return op_base64_encode_from_buffer(data, offset, length);
+}
+
 /**
  * @param {string} data
  * @returns {Uint8Array}
@@ -258,6 +273,16 @@ function forgivingBase64Decode(data) {
   return op_base64_decode(data);
 }
 
+/**
+ * @param {string} data
+ * @param {Uint8Array} target
+ * @param {number} offset
+ * @returns {number}
+ */
+function forgivingBase64DecodeInto(data, target, offset) {
+  return op_base64_decode_into(data, target, offset);
+}
+
 // Taken from std/encoding/base64url.ts
 /*
  * Some variants allow or require omitting the padding '=' signs:
@@ -455,7 +480,9 @@ export {
   collectHttpQuotedString,
   collectSequenceOfCodepoints,
   forgivingBase64Decode,
+  forgivingBase64DecodeInto,
   forgivingBase64Encode,
+  forgivingBase64EncodeFromBuffer,
   forgivingBase64UrlDecode,
   forgivingBase64UrlEncode,
   HTTP_QUOTED_STRING_TOKEN_POINT,

filename: ext/web/lib.rs
patch: @@ -61,7 +61,9 @@ deno_core::extension!(deno_web,
   deps = [ deno_webidl ],
   ops = [
     op_base64_decode,
+    op_base64_decode_into,
     op_base64_encode,
+    op_base64_encode_from_buffer,
     op_base64_atob,
     op_base64_btoa,
     op_encoding_normalize_label,
@@ -175,11 +177,69 @@ pub enum WebError {
 }
 
 #[op2]
-fn op_base64_decode(#[string] input: String) -> Result<Uint8Array, WebError> {
-  let mut s = input.into_bytes();
-  let decoded_len = forgiving_base64_decode_inplace(&mut s)?;
-  s.truncate(decoded_len);
-  Ok(s.into())
+fn op_base64_decode(
+  #[string(onebyte)] input: Cow<[u8]>,
+) -> Result<Uint8Array, WebError> {
+  // Use forgiving_decode_to_vec which handles whitespace stripping
+  // and decodes in one pass without unnecessary intermediate copies.
+  let v = base64_simd::forgiving_decode_to_vec(&input)
+    .map_err(|_| WebError::Base64Decode)?;
+  Ok(v.into())
+}
+
+/// Decode base64 directly into a target buffer at the given offset.
+/// Returns the number of bytes written.
+///
+/// Fast path: tries STANDARD.decode directly into target (zero intermediate
+/// copies). This works for properly-padded base64 without whitespace.
+/// Slow path: uses a stack/heap buffer with forgiving_decode for inputs
+/// with whitespace or missing padding.
+#[op2(fast)]
+fn op_base64_decode_into(
+  #[string(onebyte)] input: Cow<[u8]>,
+  #[buffer] target: &mut [u8],
+  #[smi] offset: u32,
+) -> Result<u32, WebError> {
+  let offset = offset as usize;
+  let target = &mut target[offset..];
+
+  // Fast path: try strict STANDARD decode directly into target.
+  // This avoids any intermediate buffer — input is read directly and
+  // decoded bytes are written to target. Works for clean padded base64
+  // (the common case from Buffer.toString('base64')).
+  //
+  // Guard: base64_simd::STANDARD.decode asserts dst.len() >= decoded_length
+  // internally (panics rather than returning Err), so we must check that
+  // the target is large enough before calling it.
+  {
+    use base64_simd::AsOut;
+    let max_decoded_len = input.len() / 4 * 3;
+    if target.len() >= max_decoded_len
+      && let Ok(decoded) = base64_simd::STANDARD.decode(&input, target.as_out())
+    {
+      return Ok(decoded.len() as u32);
+    }
+  }
+
+  // Slow path: forgiving decode for whitespace/missing padding/unpadded input.
+  // Needs a buffer >= input length for forgiving_decode's internal copy.
+  let len = input.len();
+  const STACK_BUF_SIZE: usize = 8192;
+  if len <= STACK_BUF_SIZE {
+    let mut buf = [0u8; STACK_BUF_SIZE];
+    use base64_simd::AsOut;
+    let decoded = base64_simd::forgiving_decode(&input, buf[..len].as_out())
+      .map_err(|_| WebError::Base64Decode)?;
+    let bytes_to_write = decoded.len().min(target.len());
+    target[..bytes_to_write].copy_from_slice(&decoded[..bytes_to_write]);
+    Ok(bytes_to_write as u32)
+  } else {
+    let mut s = input.into_owned();
+    let decoded_len = forgiving_base64_decode_inplace(&mut s)?;
+    let bytes_to_write = decoded_len.min(target.len());
+    target[..bytes_to_write].copy_from_slice(&s[..bytes_to_write]);
+    Ok(bytes_to_write as u32)
+  }
 }
 
 #[op2]
@@ -205,6 +265,44 @@ fn op_base64_encode(#[buffer] s: &[u8]) -> String {
   forgiving_base64_encode(s)
 }
 
+/// Encode a sub-range of a buffer to base64, avoiding a JS-side slice copy.
+#[op2]
+fn op_base64_encode_from_buffer<'a>(
+  scope: &mut v8::PinScope<'a, '_>,
+  #[buffer] s: &[u8],
+  #[smi] offset: u32,
+  #[smi] length: u32,
+) -> Result<v8::Local<'a, v8::String>, WebError> {
+  let offset = offset as usize;
+  let length = length as usize;
+  let end = (offset + length).min(s.len());
+  base64_encode_to_v8_string(scope, &s[offset..end])
+}
+
+/// Encode bytes to base64 and create a V8 one-byte string directly.
+/// Uses v8::String::new_from_one_byte (base64 output is always ASCII).
+/// Stack-allocates for inputs up to 6KB (producing ≤8KB base64).
+#[inline]
+fn base64_encode_to_v8_string<'a>(
+  scope: &mut v8::PinScope<'a, '_>,
+  src: &[u8],
+) -> Result<v8::Local<'a, v8::String>, WebError> {
+  use base64_simd::AsOut;
+  let b64_len = src.len().div_ceil(3) * 4;
+
+  const STACK_BUF_SIZE: usize = 8192;
+  if b64_len <= STACK_BUF_SIZE {
+    let mut buf = [0u8; STACK_BUF_SIZE];
+    let encoded = base64_simd::STANDARD.encode(src, buf[..b64_len].as_out());
+    v8::String::new_from_one_byte(scope, encoded, v8::NewStringType::Normal)
+      .ok_or(WebError::BufferTooLong)
+  } else {
+    let encoded = base64_simd::STANDARD.encode_type::<Vec<u8>>(src);
+    v8::String::new_from_one_byte(scope, &encoded, v8::NewStringType::Normal)
+      .ok_or(WebError::BufferTooLong)
+  }
+}
+
 #[op2]
 #[string]
 fn op_base64_btoa(#[scoped] s: ByteString) -> String {