Skip to content

Use simdutf for faster string encoding/decoding#976

Open
wh201906 wants to merge 15 commits intomargelo:mainfrom
wh201906:wh201906/simdutf
Open

Use simdutf for faster string encoding/decoding#976
wh201906 wants to merge 15 commits intomargelo:mainfrom
wh201906:wh201906/simdutf

Conversation

@wh201906
Copy link
Copy Markdown

@wh201906 wh201906 commented Apr 6, 2026

This PR migrates some string encoding/decoding functions in HybridUtils from OpenSSL/manual implementations to simdutf:

These changes significantly improve performance when encoding/decoding large base64 payloads. I ran the benchmark on an old Android device and here are the results:
(I added some test cases with 1kB data, but this PR doesn't include them)
base64 1MB encode throughput compared with CraftzdogBuffer: From 4.92x to 9.16x
base64 1MB decode throughput compared with CraftzdogBuffer: From 169.07x to 398.76x

  • old
IMG_20260406_205200
  • new
IMG_20260406_220227

@wh201906
Copy link
Copy Markdown
Author

wh201906 commented Apr 6, 2026

There are some ongoing optimizations in simdutf, which can also be utilized in RNQC once implemented.
simdutf/simdutf#925
simdutf/simdutf#565

@boorad
Copy link
Copy Markdown
Collaborator

boorad commented Apr 8, 2026

I mean... let's find a better way to download a pre-built library. I'm not adding 82K LOC to the repo.

@wh201906
Copy link
Copy Markdown
Author

wh201906 commented Apr 8, 2026

I've added simdutf as a submodule and modified the CMakeLists.txt for Android and QuickCrypto.podspec for iOS. I tested the builds for the two mobile platforms and both of them passed.
Would you mind testing it via the CI? I'm a first-time contributor so the CI won't run for this PR without approval.

@boorad
Copy link
Copy Markdown
Collaborator

boorad commented Apr 14, 2026

Claude review 😇

The Good

  1. simdutf is a solid choice — it's the same library Node.js itself uses for string encoding. SIMD-accelerated base64 on ARM (NEON) is a real win for mobile, and the benchmark numbers (4.9x → 9.2x encode, 169x → 399x decode) are impressive.

  2. Removing decodeBase64Url as a separate function is smart — simdutf's base64_default_or_url_accept_garbage option handles both base64 and base64url in one decoder, which simplifies the code and matches Node.js behavior (where Buffer.from(str, 'base64') also accepts URL-safe characters).

  3. Good test additions — the new tests for cross-format acceptance (base64 accepting URL-safe chars and vice versa) and padding/trailing-data behavior match Node.js semantics.

  4. Latin1 encoding via simdutf is a clean replacement for the manual byte-by-byte loop.

ArrayBuffer::move — Yes, It's a New Nitro API

ArrayBuffer::move(std::vector<uint8_t>&&) was added in a newer Nitro Modules version (the project is on 0.29.1, it appears around 0.34-0.35). It takes ownership of the vector's heap allocation via move semantics — no copy. This is strictly better than the old ToNativeArrayBuffer(vec) pattern which always new uint8_t[] + memcpy'd.

However, this PR would not compile against the current Nitro version (0.29.1). It requires a Nitro upgrade. The PR doesn't mention this dependency. Also note the ArrayBuffer::copy call for the utf8 path — that one is fine, it exists in 0.29.1.

Concerns / Questions

  1. Nitro version dependency is undeclared — this is a breaking change if merged without bumping Nitro. Should be called out.

  2. Submodule adds significant weight — simdutf is a large library (~hundreds of KB of generated SIMD code). The author mentions no significant APK size increase, which is good, but the single-header limited-feature generation option mentioned in the PR description should probably be used to keep it lean (we only need base64 + latin1→utf8).

  3. base64_default_or_url_accept_garbage — the name says "accept garbage," which means it's lenient. This matches Node.js behavior, but it's worth verifying edge cases where malformed input should throw vs silently decode. The tests cover the padding-stop behavior, which is good.

  4. encodeLatin1 zero-check may be wrongsimdutf::convert_latin1_to_utf8 returns 0 on error, but also returns 0 for empty input. The early len == 0 return handles it, but it's worth noting the function contract.

  5. cmake_minimum_required bump from 3.10 to 3.15 — probably needed by simdutf's CMakeLists, but worth confirming it doesn't break any CI/older Android NDK versions.

  6. Our ToNativeArrayBuffer helpers become partially dead code — the string and vector overloads are no longer used in HybridUtils. If we're going to adopt ArrayBuffer::move/::copy project-wide, we should migrate the other callsites too (ECDH, cipher, etc.) and remove the old helpers. Otherwise it's inconsistent.

Summary

Good PR with real perf wins, clean code reduction, and correct Node.js-aligned semantics. The main issue is the undeclared Nitro Modules version dependency — this needs a Nitro upgrade to compile. Worth asking the contributor if they tested against a newer Nitro version, and whether we should bundle the Nitro upgrade into this PR or do it separately first.

@wh201906
Copy link
Copy Markdown
Author

ArrayBuffer::move(std::vector<uint8_t>&&) was added in a newer Nitro Modules version (the project is on 0.29.1, it appears around 0.34-0.35).

I checked the code of nitro and found it is available in v0.31.2. We only need to bump nitro to that version.
mrousavy/nitro@f2ddc45#diff-7bf06be05b9debc132d1aa90c40244197990ea37eeceecb88e0fbcc099c43b9cR35-R39

Alternatively, considering the implementation of move() is only few lines, I can also implement it in RNQC without bumping the nitro.

Which way do you prefer?

@wh201906
Copy link
Copy Markdown
Author

wh201906 commented Apr 15, 2026

but the single-header limited-feature generation option mentioned in the PR description should probably be used to keep it lean

This involves running a Python script from simdutf when building RNQC, I'm not sure if it's a good idea to require Python as a dev dependency with extra building steps, considering how small the binary size we can save.

@wh201906
Copy link
Copy Markdown
Author

base64_default_or_url_accept_garbage — the name says "accept garbage," which means it's lenient. This matches Node.js behavior, but it's worth verifying edge cases where malformed input should throw vs silently decode. The tests cover the padding-stop behavior, which is good.

I can add more test cases of Buffer.from()/Buffer.toString() from Node.js if you wish. I only ensured this PR doesn't break any existing test cases.

@wh201906
Copy link
Copy Markdown
Author

encodeLatin1 zero-check may be wrong — simdutf::convert_latin1_to_utf8 returns 0 on error, but also returns 0 for empty input. The early len == 0 return handles it, but it's worth noting the function contract.

This is more like a fast path. If the input length is 0, no need to calculate the output length and enter the conversion.

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