Repository: denoland/deno
Pull request: perf(ext/web): use simdutf for base64 encode/decode

## Summary
- Replace `base64-simd` crate with V8's bundled simdutf library (exposed via `v8::simdutf`) for all base64 encode/decode operations
- Update `v8` crate to 146.9.0 and enable the `simdutf` feature
- Remove `base64-simd` dependency from `ext/web`
- Use `v8::String::new_external_onebyte` for large base64 encode output (>8KB) to avoid copying into V8's heap, matching Node.js's approach
- Use `MaybeUninit` / `Vec::with_capacity` to avoid unnecessary zeroing of buffers before simdutf writes
- Call base64 ops directly from `buffer.mjs` instead of going through JS wrapper functions
- Avoids potential C++ symbol clashes when linking separate simdutf alongside V8

## Benchmark results (50K iterations, ms, lower is better)

| Size | Op | this PR | canary | stable | node | bun |
|------|-------|---------|--------|--------|------|-----|
| 32B | Encode | 4.7 | 8.7 | 9.1 | 5.5 | **3.3** |
| 32B | Decode | 5.6 | 10.1 | 11.2 | **3.7** | 7.0 |
| 32B | Write | 2.4 | 5.1 | 7.0 | 2.8 | **2.1** |
| 128B | Encode | 3.1 | 3.0 | 3.1 | 2.7 | **2.4** |
| 128B | Decode | **3.7** | 7.2 | 8.9 | 4.1 | 5.4 |
| 128B | Write | **1.9** | 5.2 | 7.4 | 3.0 | 2.4 |
| 512B | Encode | 5.2 | 6.0 | 6.2 | **3.9** | 6.6 |
| 512B | Decode | **5.5** | 10.0 | 13.6 | 6.0 | 6.5 |
| 512B | Write | **2.8** | 7.9 | 11.6 | 4.4 | 3.2 |
| 4KB | Encode | 28.8 | 36.5 | 36.8 | **16.0** | 16.1 |
| 4KB | Decode | 30.3 | 53.7 | 69.6 | 31.8 | **21.9** |
| 4KB | Write | **14.2** | 35.3 | 50.9 | 18.3 | 14.7 |
| 64KB | Encode | 213.4 | 458.9 | 583.3 | 244.2 | **143.3** |
| 64KB | Decode | 429.0 | 709.3 | 843.8 | 320.1 | **236.3** |
| 64KB | Write | 341.4 | 617.3 | 732.0 | 265.7 | **214.8** |
| 1MB | Encode | 2343.1 | 6039.8 | 8201.8 | **2214.4** | 2259.0 |
| 1MB | Decode | 4908.2 | 10254.0 | 11753.1 | 4411.0 | **3666.4** |
| 1MB | Write | 4499.9 | 9428.8 | 11100.3 | 4240.6 | **3434.0** |

### vs canary highlights
- Encode: 1.0-2.6x faster across all sizes
- Decode: 1.7-2.1x faster across all sizes
- Write: 1.8-2.5x faster across all sizes
- atob: 6.8ms vs 7.4ms (1.1x faster)

Towards https://github.com/denoland/deno/issues/24323

<details>
<summary>Benchmark code</summary>

```js
const { Buffer } = await import("node:buffer");

const ITERATIONS = 50000;
const sizes = [32, 128, 512, 4096, 65536, 1048576];

function bench(label, fn, iterations) {
  // Warmup
  for (let i = 0; i < 100; i++) fn();
  const start = performance.now();
  for (let i = 0; i < iterations; i++) fn();
  const elapsed = performance.now() - start;
  return elapsed;
}

console.log(`\nBase64 Benchmark (${ITERATIONS} iterations)\n`);
console.log("Size     | Encode (ms) | Decode (ms) | Write (ms)");
console.log("---------|-------------|-------------|----------");

for (const size of sizes) {
  const buf = Buffer.alloc(size, 0x42);
  const b64str = buf.toString("base64");
  const targetBuf = Buffer.alloc(size);

  const encodeMs = bench(`encode ${size}`, () => {
    buf.toString("base64");
  }, ITERATIONS);

  const decodeMs = bench(`decode ${size}`, () => {
    Buffer.from(b64str, "base64");
  }, ITERATIONS);

  const writeMs = bench(`write ${size}`, () => {
    targetBuf.write(b64str, "base64");
  }, ITERATIONS);

  const sizeLabel = size >= 1024
    ? `${size / 1024}KB`.padEnd(8)
    : `${size}B`.padEnd(8);
  console.log(
    `${sizeLabel} | ${encodeMs.toFixed(1).padStart(11)} | ${decodeMs.toFixed(1).padStart(11)} | ${writeMs.toFixed(1).padStart(10)}`,
  );
}

// Also test atob/btoa
console.log("\natob/btoa Benchmark (50000 iterations)\n");
const testStr = Buffer.alloc(512, 0x42).toString("base64");
const aMs = bench("btoa", () => btoa("Hello, World!"), 50000);
const bMs = bench("atob", () => atob(testStr), 50000);
console.log(`btoa: ${aMs.toFixed(1)}ms`);
console.log(`atob: ${bMs.toFixed(1)}ms`);
```

</details>

## Test plan
- [x] `cargo clippy -p deno_web` passes
- [x] `./tools/format.js` passes
- [ ] CI tests

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Changed files:
filename: Cargo.lock
patch: @@ -3425,7 +3425,6 @@ name = "deno_web"
 version = "0.271.0"
 dependencies = [
  "async-trait",
- "base64-simd",
  "brotli",
  "bytes",
  "deno_bench_util",
@@ -11179,9 +11178,9 @@ dependencies = [
 
 [[package]]
 name = "v8"
-version = "146.8.0"
+version = "146.9.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "67519d234f7bc5a54a71f7fbc52258b0f3a7e9c11f365213d49016a59207ec10"
+checksum = "59b21777080203cb33828681f177e53402fa2927a1e2d17adead072b0c26b401"
 dependencies = [
  "bindgen 0.72.1",
  "bitflags 2.9.3",

filename: Cargo.toml
patch: @@ -99,7 +99,7 @@ deno_task_shell = "=0.29.0"
 deno_terminal = "=0.2.3"
 deno_unsync = { version = "0.4.4", default-features = false }
 deno_whoami = "0.1.0"
-v8 = { version = "146.8.0", default-features = false }
+v8 = { version = "146.9.0", default-features = false, features = ["simdutf"] }
 
 denokv_proto = "0.13.0"
 denokv_remote = "0.13.0"

filename: ext/node/polyfills/internal/buffer.mjs
patch: @@ -62,6 +62,9 @@ const {
   Uint8ArrayPrototype,
 } = primordials;
 import {
+  op_base64_decode_into,
+  op_base64_encode,
+  op_base64_encode_from_buffer,
   op_is_ascii,
   op_is_utf8,
   op_mark_as_untransferable,
@@ -110,12 +113,7 @@ import {
   NodeError,
 } 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 { forgivingBase64UrlEncode } from "ext:deno_web/00_infra.js";
 import { atob, btoa } from "ext:deno_web/05_base64.js";
 import { Blob, blobFromObjectUrl, File } from "ext:deno_web/09_file.js";
 import { untransferableSymbol } from "ext:deno_node/internal_binding/util.ts";
@@ -982,14 +980,14 @@ Buffer.prototype.base64Slice = function base64Slice(
   offset,
   length,
 ) {
-  // Use forgivingBase64Encode (#[string] return) for small buffers where
+  // Use op_base64_encode (#[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.
+  // Use op_base64_encode_from_buffer (v8::String::new_external_onebyte) for
+  // large buffers where avoiding UTF-8 processing and copying matters.
   if (offset === 0 && length === this.length && length <= 4096) {
-    return forgivingBase64Encode(this);
+    return op_base64_encode(this);
   }
-  return forgivingBase64EncodeFromBuffer(this, offset, length - offset);
+  return op_base64_encode_from_buffer(this, offset, length - offset);
 };
 
 Buffer.prototype.base64Write = function base64Write(
@@ -998,7 +996,7 @@ Buffer.prototype.base64Write = function base64Write(
   length,
 ) {
   try {
-    const written = forgivingBase64DecodeInto(string, this, offset);
+    const written = op_base64_decode_into(string, this, offset);
     return length !== undefined ? MathMin(written, length) : written;
   } catch {
     // Fallback for strings with base64url chars or invalid chars
@@ -1196,9 +1194,9 @@ function fromArrayBuffer(obj, byteOffset, length) {
 
 function _base64Slice(buf, start, end) {
   if (start === 0 && end === buf.length && end <= 4096) {
-    return forgivingBase64Encode(buf);
+    return op_base64_encode(buf);
   }
-  return forgivingBase64EncodeFromBuffer(buf, start, end - start);
+  return op_base64_encode_from_buffer(buf, start, end - start);
 }
 const decoder = new TextDecoder("utf-8", { ignoreBOM: true });
 

filename: ext/web/Cargo.toml
patch: @@ -15,7 +15,6 @@ path = "lib.rs"
 
 [dependencies]
 async-trait.workspace = true
-base64-simd.workspace = true
 brotli.workspace = true
 bytes.workspace = true
 deno_core.workspace = true

filename: ext/web/lib.rs
patch: @@ -176,24 +176,151 @@ pub enum WebError {
   DataError(#[from] v8::DataError),
 }
 
+/// Forgiving base64 decode using simdutf. Decodes into a new Vec.
+/// Handles whitespace stripping and missing padding (Loose mode).
+#[inline]
+fn simdutf_base64_decode_to_vec(input: &[u8]) -> Result<Vec<u8>, WebError> {
+  use v8::simdutf;
+  let max_len = simdutf::maximal_binary_length_from_base64(input);
+  let mut output = Vec::with_capacity(max_len);
+  // Safety: output has max_len bytes of capacity which is >= decoded size.
+  // ffi_base64_to_binary writes into the buffer without reading uninitialized data.
+  let result = unsafe {
+    ffi_base64_to_binary(
+      input.as_ptr(),
+      input.len(),
+      output.as_mut_ptr(),
+      simdutf::Base64Options::Default as u64,
+      simdutf::LastChunkHandling::Loose as u64,
+    )
+  };
+  // error == 0 means success (simdutf error_code::SUCCESS)
+  if result.error != 0 {
+    return Err(WebError::Base64Decode);
+  }
+  // Safety: base64_to_binary wrote result.count bytes.
+  unsafe { output.set_len(result.count) };
+  Ok(output)
+}
+
+/// Forgiving base64 decode into an existing buffer using simdutf.
+/// Returns the number of bytes written.
+#[inline]
+fn simdutf_base64_decode_into(
+  input: &[u8],
+  output: &mut [u8],
+) -> Result<usize, WebError> {
+  use v8::simdutf;
+  // Safety: caller provides output buffer with sufficient capacity.
+  let result = unsafe {
+    simdutf::base64_to_binary(
+      input,
+      output,
+      simdutf::Base64Options::Default,
+      simdutf::LastChunkHandling::Loose,
+    )
+  };
+  if !result.is_ok() {
+    return Err(WebError::Base64Decode);
+  }
+  Ok(result.count)
+}
+
+/// Strict base64 decode directly into target buffer.
+/// Returns None if the input is not valid strict padded base64.
+#[inline]
+fn simdutf_base64_decode_strict(
+  input: &[u8],
+  output: &mut [u8],
+) -> Option<usize> {
+  use v8::simdutf;
+  // Safety: caller provides output buffer with sufficient capacity.
+  let result = unsafe {
+    simdutf::base64_to_binary(
+      input,
+      output,
+      simdutf::Base64Options::Default,
+      simdutf::LastChunkHandling::Strict,
+    )
+  };
+  if result.is_ok() {
+    Some(result.count)
+  } else {
+    None
+  }
+}
+
+// Re-declare simdutf FFI functions to allow passing raw pointers
+// without constructing &mut [u8] from uninitialized memory (which is UB).
+#[repr(C)]
+struct SimdutfFfiResult {
+  error: i32,
+  count: usize,
+}
+
+unsafe extern "C" {
+  #[link_name = "simdutf__binary_to_base64"]
+  fn ffi_binary_to_base64(
+    input: *const u8,
+    length: usize,
+    output: *mut u8,
+    options: u64,
+  ) -> usize;
+
+  #[link_name = "simdutf__base64_to_binary"]
+  fn ffi_base64_to_binary(
+    input: *const u8,
+    length: usize,
+    output: *mut u8,
+    options: u64,
+    last_chunk_options: u64,
+  ) -> SimdutfFfiResult;
+}
+
+/// Encode binary to base64 using simdutf. Returns encoded length.
+///
+/// # Safety
+/// `output` must point to at least `base64_length_from_binary(input.len())`
+/// writable bytes. The bytes do not need to be initialized.
+#[inline]
+unsafe fn simdutf_base64_encode(
+  input: &[u8],
+  output: *mut u8,
+  output_len: usize,
+) -> usize {
+  debug_assert!(
+    output_len
+      >= v8::simdutf::base64_length_from_binary(
+        input.len(),
+        v8::simdutf::Base64Options::Default
+      )
+  );
+  // Safety: caller guarantees output has sufficient capacity.
+  unsafe {
+    ffi_binary_to_base64(
+      input.as_ptr(),
+      input.len(),
+      output,
+      v8::simdutf::Base64Options::Default as u64,
+    )
+  }
+}
+
 #[op2]
 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)?;
+  let v = simdutf_base64_decode_to_vec(&input)?;
   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
+/// Fast path: tries strict 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.
+/// Slow path: uses forgiving decode for inputs with whitespace or missing
+/// padding.
 #[op2(fast)]
 fn op_base64_decode_into(
   #[string(onebyte)] input: Cow<[u8]>,
@@ -203,60 +330,67 @@ fn op_base64_decode_into(
   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.
+  // Fast path: try strict decode directly into target.
+  // Works for clean padded base64 (the common case).
+  let max_len = v8::simdutf::maximal_binary_length_from_base64(&input);
+  if target.len() >= max_len
+    && let Some(len) = simdutf_base64_decode_strict(&input, target)
   {
-    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);
-    }
+    return Ok(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();
+  // Slow path: forgiving decode for whitespace/missing padding.
   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]);
+  if max_len <= STACK_BUF_SIZE {
+    let mut buf = std::mem::MaybeUninit::<[u8; STACK_BUF_SIZE]>::uninit();
+    // Safety: simdutf writes into buf without reading uninitialized data.
+    let decoded_len = simdutf_base64_decode_into(&input, unsafe {
+      std::slice::from_raw_parts_mut(
+        buf.as_mut_ptr() as *mut u8,
+        STACK_BUF_SIZE,
+      )
+    })?;
+    let bytes_to_write = decoded_len.min(target.len());
+    // Safety: decoded_len bytes were written by simdutf.
+    target[..bytes_to_write].copy_from_slice(unsafe {
+      std::slice::from_raw_parts(buf.as_ptr() as *const u8, 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]);
+    let decoded = simdutf_base64_decode_to_vec(&input)?;
+    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)
   }
 }
 
 #[op2]
 fn op_base64_atob(#[scoped] mut s: ByteString) -> Result<ByteString, WebError> {
-  let decoded_len = forgiving_base64_decode_inplace(&mut s)?;
-  s.truncate(decoded_len);
-  Ok(s)
-}
-
-/// See <https://infra.spec.whatwg.org/#forgiving-base64>
-#[inline]
-fn forgiving_base64_decode_inplace(
-  input: &mut [u8],
-) -> Result<usize, WebError> {
-  let decoded = base64_simd::forgiving_decode_inplace(input)
-    .map_err(|_| WebError::Base64Decode)?;
-  Ok(decoded.len())
+  // Decode into a temporary buffer — simdutf requires non-overlapping buffers.
+  let max_len = v8::simdutf::maximal_binary_length_from_base64(&s);
+  const STACK_BUF_SIZE: usize = 8192;
+  if max_len <= STACK_BUF_SIZE {
+    let mut buf = std::mem::MaybeUninit::<[u8; STACK_BUF_SIZE]>::uninit();
+    // Safety: simdutf writes into buf without reading uninitialized data.
+    let decoded_len = simdutf_base64_decode_into(&s, unsafe {
+      std::slice::from_raw_parts_mut(
+        buf.as_mut_ptr() as *mut u8,
+        STACK_BUF_SIZE,
+      )
+    })?;
+    // Safety: decoded_len bytes were written by simdutf.
+    s[..decoded_len].copy_from_slice(unsafe {
+      std::slice::from_raw_parts(buf.as_ptr() as *const u8, decoded_len)
+    });
+    s.truncate(decoded_len);
+    Ok(s)
+  } else {
+    let decoded = simdutf_base64_decode_to_vec(&s)?;
+    let decoded_len = decoded.len();
+    s[..decoded_len].copy_from_slice(&decoded[..decoded_len]);
+    s.truncate(decoded_len);
+    Ok(s)
+  }
 }
 
 #[op2]
@@ -280,26 +414,46 @@ fn op_base64_encode_from_buffer<'a>(
 }
 
 /// 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).
+/// Stack-allocates for inputs producing ≤8KB base64.
+/// Uses v8::String::new_external_onebyte for large outputs to avoid copying.
 #[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;
+  let b64_len = v8::simdutf::base64_length_from_binary(
+    src.len(),
+    v8::simdutf::Base64Options::Default,
+  );
 
   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)
+    let mut buf = std::mem::MaybeUninit::<[u8; STACK_BUF_SIZE]>::uninit();
+    // Safety: buf has STACK_BUF_SIZE >= b64_len bytes.
+    // simdutf writes `written` bytes without reading uninitialized data.
+    let written = unsafe {
+      simdutf_base64_encode(src, buf.as_mut_ptr() as *mut u8, b64_len)
+    };
+    v8::String::new_from_one_byte(
+      scope,
+      // Safety: written <= b64_len <= STACK_BUF_SIZE, all initialized.
+      unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, written) },
+      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)
+    // Encode into a boxed slice and hand ownership to V8 via external string.
+    // This avoids a copy — V8 will free the buffer when the string is GC'd.
+    let mut buf = Vec::with_capacity(b64_len);
+    // Safety: buf has b64_len bytes of capacity.
+    // binary_to_base64 writes exactly b64_len bytes without reading.
+    let written =
+      unsafe { simdutf_base64_encode(src, buf.as_mut_ptr(), b64_len) };
+    // Safety: written bytes are initialized by binary_to_base64.
+    unsafe { buf.set_len(written) };
+    let buf = buf.into_boxed_slice();
+    debug_assert_eq!(written, b64_len);
+    v8::String::new_external_onebyte(scope, buf).ok_or(WebError::BufferTooLong)
   }
 }
 
@@ -312,7 +466,18 @@ fn op_base64_btoa(#[scoped] s: ByteString) -> String {
 /// See <https://infra.spec.whatwg.org/#forgiving-base64>
 #[inline]
 pub fn forgiving_base64_encode(s: &[u8]) -> String {
-  base64_simd::STANDARD.encode_to_string(s)
+  let b64_len = v8::simdutf::base64_length_from_binary(
+    s.len(),
+    v8::simdutf::Base64Options::Default,
+  );
+  let mut buf = Vec::with_capacity(b64_len);
+  // Safety: buf has b64_len bytes of capacity.
+  // binary_to_base64 writes up to b64_len bytes, all valid ASCII.
+  unsafe {
+    let written = simdutf_base64_encode(s, buf.as_mut_ptr(), b64_len);
+    buf.set_len(written);
+    String::from_utf8_unchecked(buf)
+  }
 }
 
 #[op2]